Revert "Revert "add Brush Designer ViewModel, preview, and app wiring""#70
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the 'Brush Designer' feature, enabling users to create, test, and export custom ink brushes. The implementation includes a new ViewModel for Protobuf manipulation, adaptive UI components for the designer interface, and integration into the app's navigation and settings. Feedback highlights critical performance and stability improvements for the BrushDesignerViewModel, specifically recommending that texture decoding be moved to a background thread to avoid UI stutters and that defensive bounds checks be added to coat update methods to prevent potential crashes.
|
|
||
| fun setTextureStore(store: CahierTextureBitmapStore) { | ||
| this.textureStore = store | ||
| // Immediately populate the store from any textures already in the proto, | ||
| // since previewBrushFamily's decode callback has a 150ms debounce. | ||
| val bitmapMap = repository.activeBrushProto.value.textureIdToBitmapMap | ||
| bitmapMap.forEach { (id, byteString) -> | ||
| if (store.get(id) == null) { | ||
| try { | ||
| val bytes = byteString.toByteArray() | ||
| BitmapFactory.decodeByteArray(bytes, 0, bytes.size) | ||
| ?.let { store.loadTexture(id, it) } | ||
| } catch (_: Exception) { /* logged during decode */ } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The setTextureStore function performs image decoding and texture loading on the main thread. Since this is called from a LaunchedEffect in the UI (PreviewPane.kt), it will block the main thread and cause UI stutters or ANRs if the brush contains large or multiple textures. This logic should be moved to a background thread using viewModelScope.launch(Dispatchers.IO).
fun setTextureStore(store: CahierTextureBitmapStore) {
this.textureStore = store
// Immediately populate the store from any textures already in the proto,
// since previewBrushFamily's decode callback has a 150ms debounce.
viewModelScope.launch(Dispatchers.IO) {
val bitmapMap = repository.activeBrushProto.value.textureIdToBitmapMap
bitmapMap.forEach { (id, byteString) ->
if (store.get(id) == null) {
try {
val bytes = byteString.toByteArray()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?.let { store.loadTexture(id, it) }
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to decode texture $id", e)
}
}
}
}
}| fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) { | ||
| val familyBuilder = repository.activeBrushProto.value.toBuilder() | ||
| val index = _selectedCoatIndex.value | ||
|
|
||
| val coatBuilder = familyBuilder.getCoats(index).toBuilder() | ||
| val tipBuilder = coatBuilder.tip.toBuilder() | ||
| updateBlock(tipBuilder) | ||
|
|
||
| coatBuilder.setTip(tipBuilder) | ||
| familyBuilder.setCoats(index, coatBuilder) | ||
| repository.updateActiveBrushProto(familyBuilder.build()) | ||
| } |
There was a problem hiding this comment.
The updateTip function lacks a bounds check for _selectedCoatIndex. If the index is invalid (e.g., due to a race condition or malformed proto load), familyBuilder.getCoats(index) will throw an IndexOutOfBoundsException. A defensive check should be added to ensure the index is within the current coatsCount.
| fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) { | |
| val familyBuilder = repository.activeBrushProto.value.toBuilder() | |
| val index = _selectedCoatIndex.value | |
| val coatBuilder = familyBuilder.getCoats(index).toBuilder() | |
| val tipBuilder = coatBuilder.tip.toBuilder() | |
| updateBlock(tipBuilder) | |
| coatBuilder.setTip(tipBuilder) | |
| familyBuilder.setCoats(index, coatBuilder) | |
| repository.updateActiveBrushProto(familyBuilder.build()) | |
| } | |
| fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) { | |
| val familyBuilder = repository.activeBrushProto.value.toBuilder() | |
| val index = _selectedCoatIndex.value | |
| if (index < 0 || index >= familyBuilder.coatsCount) return | |
| val coatBuilder = familyBuilder.getCoats(index).toBuilder() | |
| val tipBuilder = coatBuilder.tip.toBuilder() | |
| updateBlock(tipBuilder) | |
| coatBuilder.setTip(tipBuilder) | |
| familyBuilder.setCoats(index, coatBuilder) | |
| repository.updateActiveBrushProto(familyBuilder.build()) | |
| } |
| fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) { | ||
| val familyBuilder = repository.activeBrushProto.value.toBuilder() | ||
| val index = _selectedCoatIndex.value | ||
|
|
||
| if (familyBuilder.coatsCount <= index) { | ||
| familyBuilder.addCoats( | ||
| ProtoBrushCoat.newBuilder() | ||
| .setTip(ProtoBrushTip.newBuilder()) | ||
| ) | ||
| } | ||
| val coatBuilder = familyBuilder.getCoats(index).toBuilder() | ||
|
|
||
| if (coatBuilder.paintPreferencesCount == 0) { | ||
| coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder()) | ||
| } | ||
| val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder() | ||
|
|
||
| paintBuilder.selfOverlap = overlap | ||
|
|
||
| coatBuilder.setPaintPreferences(0, paintBuilder) | ||
| familyBuilder.setCoats(index, coatBuilder) | ||
| repository.updateActiveBrushProto(familyBuilder.build()) | ||
| } |
There was a problem hiding this comment.
The bounds check in updateSelfOverlap is flawed. If index is significantly greater than coatsCount, calling addCoats once (line 269) will not make index valid, leading to an IndexOutOfBoundsException at line 274. It is safer to verify the index is within bounds and return early if not, maintaining consistency with other update methods.
fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) {
val familyBuilder = repository.activeBrushProto.value.toBuilder()
val index = _selectedCoatIndex.value
if (index < 0 || index >= familyBuilder.coatsCount) return
val coatBuilder = familyBuilder.getCoats(index).toBuilder()
if (coatBuilder.paintPreferencesCount == 0) {
coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder())
}
val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder()
paintBuilder.selfOverlap = overlap
coatBuilder.setPaintPreferences(0, paintBuilder)
familyBuilder.setCoats(index, coatBuilder)
repository.updateActiveBrushProto(familyBuilder.build())
}Also remove some strings which we won't need in PR B once that one is updated as well
See most recent commit for the fix to the build issues caused by #67. The rest of the code here was reviewed in #59.
Namely, naive experimental model being renamed to passthrough, and spring model being deleted. I also got rid of some strings we aren't going to need in #68 after that one is updated to the new proto as well (spring model, experimental naive model, and texture animation)