Skip to content

Commit e010172

Browse files
authored
Merge pull request #7 from alexdremov/bugs
Coolection of tech debt bugs
2 parents ace3470 + 2eea377 commit e010172

22 files changed

Lines changed: 922 additions & 147 deletions

app/src/main/java/com/alexdremov/notate/controller/CanvasControllerImpl.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ class CanvasControllerImpl(
298298
if (!selectionManager.hasSelection()) return
299299

300300
val originals = selectionManager.selectedItems.toList()
301-
val transform = selectionManager.transformMatrix
301+
val transform = selectionManager.getTransform()
302302

303303
val values = FloatArray(9)
304304
transform.getValues(values)
@@ -359,7 +359,7 @@ class CanvasControllerImpl(
359359

360360
selectionManager.clearSelection()
361361
selectionManager.selectAll(newSelected)
362-
selectionManager.transformMatrix.reset()
362+
selectionManager.resetTransform()
363363

364364
runOnUi {
365365
renderer.invalidate()

app/src/main/java/com/alexdremov/notate/controller/SelectionManager.kt

Lines changed: 103 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,126 @@ package com.alexdremov.notate.controller
33
import android.graphics.Matrix
44
import android.graphics.RectF
55
import com.alexdremov.notate.model.CanvasItem
6-
import java.util.concurrent.ConcurrentHashMap
76

87
/**
98
* Manages the state of the active selection.
109
* Holds the selected items and the transient transformation matrix.
1110
* Thread-safe.
1211
*/
1312
class SelectionManager {
14-
private val _selectedItems = ConcurrentHashMap.newKeySet<CanvasItem>()
15-
val selectedItems: Set<CanvasItem> get() = _selectedItems
13+
private val lock = Any()
14+
private val _selectedItems = HashSet<CanvasItem>()
15+
val selectedItems: Set<CanvasItem>
16+
get() = synchronized(lock) { _selectedItems.toSet() }
1617

1718
// Backwards compatibility for callers expecting Stroke
1819
val selectedStrokes: Set<com.alexdremov.notate.model.Stroke>
19-
get() = _selectedItems.filterIsInstance<com.alexdremov.notate.model.Stroke>().toSet()
20+
get() = synchronized(lock) { _selectedItems.filterIsInstance<com.alexdremov.notate.model.Stroke>().toSet() }
2021

2122
// Current transformation applied to the selection (transient)
22-
val transformMatrix = Matrix()
23+
private val transformMatrix = Matrix()
2324

2425
// Bounding box of the original selection (before transform)
2526
private val selectionBounds = RectF()
2627

28+
/**
29+
* Returns a defensive copy of the current transformation matrix.
30+
*
31+
* Note: this allocates a new [Matrix] on every call. For performance-critical,
32+
* read-only access that avoids allocations, use [withTransformReadLocked].
33+
*/
34+
fun getTransform(): Matrix {
35+
synchronized(lock) {
36+
return Matrix(transformMatrix)
37+
}
38+
}
39+
40+
/**
41+
* Executes [block] while holding the internal lock, providing direct read-only
42+
* access to the current transformation matrix without creating a copy.
43+
*
44+
* Callers must not mutate [Matrix] inside [block]. Violating this contract can
45+
* break invariants and thread-safety guarantees of [SelectionManager].
46+
*/
47+
fun <T> withTransformReadLocked(block: (Matrix) -> T): T {
48+
synchronized(lock) {
49+
return block(transformMatrix)
50+
}
51+
}
52+
fun resetTransform() {
53+
synchronized(lock) {
54+
transformMatrix.reset()
55+
}
56+
}
57+
2758
fun select(item: CanvasItem) {
28-
_selectedItems.add(item)
29-
recomputeBounds()
59+
synchronized(lock) {
60+
_selectedItems.add(item)
61+
recomputeBoundsInternal()
62+
}
3063
}
3164

3265
fun selectAll(items: List<CanvasItem>) {
33-
_selectedItems.addAll(items)
34-
recomputeBounds()
66+
synchronized(lock) {
67+
_selectedItems.addAll(items)
68+
recomputeBoundsInternal()
69+
}
3570
}
3671

3772
fun deselect(item: CanvasItem) {
38-
_selectedItems.remove(item)
39-
recomputeBounds()
73+
synchronized(lock) {
74+
_selectedItems.remove(item)
75+
recomputeBoundsInternal()
76+
}
4077
}
4178

4279
fun clearSelection() {
43-
_selectedItems.clear()
44-
transformMatrix.reset()
45-
selectionBounds.setEmpty()
80+
synchronized(lock) {
81+
_selectedItems.clear()
82+
transformMatrix.reset()
83+
selectionBounds.setEmpty()
84+
}
4685
}
4786

48-
fun hasSelection() = _selectedItems.isNotEmpty()
87+
fun hasSelection(): Boolean = synchronized(lock) { _selectedItems.isNotEmpty() }
4988

50-
fun isSelected(item: CanvasItem) = _selectedItems.contains(item)
89+
fun isSelected(item: CanvasItem): Boolean = synchronized(lock) { _selectedItems.contains(item) }
5190

5291
private fun recomputeBounds() {
92+
synchronized(lock) {
93+
recomputeBoundsInternal()
94+
}
95+
}
96+
97+
private fun recomputeBoundsInternal() {
5398
if (_selectedItems.isEmpty()) {
5499
selectionBounds.setEmpty()
55100
return
56101
}
57-
val iter = _selectedItems.iterator()
58-
if (iter.hasNext()) {
59-
selectionBounds.set(iter.next().bounds)
60-
}
61-
while (iter.hasNext()) {
62-
selectionBounds.union(iter.next().bounds)
102+
103+
val tempBounds = RectF()
104+
var first = true
105+
for (item in _selectedItems) {
106+
if (first) {
107+
tempBounds.set(item.bounds)
108+
first = false
109+
} else {
110+
tempBounds.union(item.bounds)
111+
}
63112
}
113+
selectionBounds.set(tempBounds)
64114
}
65115

66116
/**
67117
* Returns the bounding box of the selection with the current transform applied.
68118
* Note: This is an AABB (Axis Aligned Bounding Box) of the transformed shape.
69119
*/
70120
fun getTransformedBounds(): RectF {
71-
val r = RectF(selectionBounds)
72-
transformMatrix.mapRect(r)
73-
return r
121+
synchronized(lock) {
122+
val r = RectF(selectionBounds)
123+
transformMatrix.mapRect(r)
124+
return r
125+
}
74126
}
75127

76128
/**
@@ -79,39 +131,47 @@ class SelectionManager {
79131
* Order: Top-Left, Top-Right, Bottom-Right, Bottom-Left.
80132
*/
81133
fun getTransformedCorners(): FloatArray {
82-
val pts =
83-
floatArrayOf(
84-
selectionBounds.left,
85-
selectionBounds.top, // TL
86-
selectionBounds.right,
87-
selectionBounds.top, // TR
88-
selectionBounds.right,
89-
selectionBounds.bottom, // BR
90-
selectionBounds.left,
91-
selectionBounds.bottom, // BL
92-
)
93-
transformMatrix.mapPoints(pts)
94-
return pts
134+
synchronized(lock) {
135+
val pts =
136+
floatArrayOf(
137+
selectionBounds.left,
138+
selectionBounds.top, // TL
139+
selectionBounds.right,
140+
selectionBounds.top, // TR
141+
selectionBounds.right,
142+
selectionBounds.bottom, // BR
143+
selectionBounds.left,
144+
selectionBounds.bottom, // BL
145+
)
146+
transformMatrix.mapPoints(pts)
147+
return pts
148+
}
95149
}
96150

97151
/**
98152
* Returns the center of the selection in World coordinates,
99153
* with the current transform applied.
100154
*/
101155
fun getSelectionCenter(): FloatArray {
102-
val pts = floatArrayOf(selectionBounds.centerX(), selectionBounds.centerY())
103-
transformMatrix.mapPoints(pts)
104-
return pts
156+
synchronized(lock) {
157+
val pts = floatArrayOf(selectionBounds.centerX(), selectionBounds.centerY())
158+
transformMatrix.mapPoints(pts)
159+
return pts
160+
}
105161
}
106162

107163
fun translate(
108164
dx: Float,
109165
dy: Float,
110166
) {
111-
transformMatrix.postTranslate(dx, dy)
167+
synchronized(lock) {
168+
transformMatrix.postTranslate(dx, dy)
169+
}
112170
}
113171

114172
fun applyTransform(matrix: Matrix) {
115-
transformMatrix.postConcat(matrix)
173+
synchronized(lock) {
174+
transformMatrix.postConcat(matrix)
175+
}
116176
}
117177
}

app/src/main/java/com/alexdremov/notate/data/CanvasRepository.kt

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ class CanvasRepository(
5252
wasJson = true
5353
Logger.d("CanvasRepository", "Loaded via JSON Fallback")
5454
} catch (e2: Exception) {
55-
Logger.w("CanvasRepository", "JSON Fallback failed", e2)
55+
Logger.e("CanvasRepository", "JSON Fallback failed", e2)
56+
val ioException = java.io.IOException(
57+
"Failed to parse canvas data. Protobuf: ${e.message}, JSON: ${e2.message}",
58+
e,
59+
)
60+
ioException.addSuppressed(e2)
61+
throw ioException
5662
}
5763
}
5864
tDecode = System.currentTimeMillis()
@@ -122,18 +128,59 @@ class CanvasRepository(
122128
bytes: ByteArray,
123129
) {
124130
if (path.startsWith("content://")) {
125-
context.contentResolver.openOutputStream(Uri.parse(path), "wt")?.use {
131+
val os =
132+
context.contentResolver.openOutputStream(Uri.parse(path), "wt")
133+
?: throw java.io.IOException("Failed to open output stream for SAF path: $path")
134+
os.use {
126135
it.write(bytes)
127136
}
128137
} else {
129138
// Atomic write for local files
130139
val targetFile = File(path)
131140
val tmpFile = File(targetFile.parent, "${targetFile.name}.tmp")
132-
tmpFile.writeBytes(bytes)
133-
if (targetFile.exists()) {
134-
targetFile.delete()
141+
val backupFile = File(targetFile.parent, "${targetFile.name}.bak")
142+
143+
try {
144+
tmpFile.writeBytes(bytes)
145+
146+
if (targetFile.exists()) {
147+
// Create backup
148+
if (backupFile.exists()) backupFile.delete()
149+
if (!targetFile.renameTo(backupFile)) {
150+
throw java.io.IOException("Failed to create backup file: ${backupFile.absolutePath}")
151+
}
152+
}
153+
154+
if (!tmpFile.renameTo(targetFile)) {
155+
// Restore from backup if rename fails
156+
var restoreAttempted = false
157+
var restoreSucceeded = false
158+
if (backupFile.exists()) {
159+
restoreAttempted = true
160+
restoreSucceeded = backupFile.renameTo(targetFile)
161+
}
162+
val restoreMessage = when {
163+
restoreAttempted && !restoreSucceeded ->
164+
" Backup restore also failed; backup may remain at ${backupFile.absolutePath}."
165+
restoreAttempted && restoreSucceeded ->
166+
" Backup restored successfully from ${backupFile.absolutePath}."
167+
else -> ""
168+
}
169+
throw java.io.IOException(
170+
"Failed to rename temp file to: ${targetFile.absolutePath}.$restoreMessage"
171+
)
172+
}
173+
174+
// Success, delete backup
175+
if (backupFile.exists()) {
176+
backupFile.delete()
177+
}
178+
} finally {
179+
// Cleanup tmp file if it still exists (e.g. if delete or rename failed)
180+
if (tmpFile.exists()) {
181+
tmpFile.delete()
182+
}
135183
}
136-
tmpFile.renameTo(targetFile)
137184
}
138185
}
139186

0 commit comments

Comments
 (0)