From e86a925d8a57416b62e31ba538ea9bcbbb339daa Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 4 May 2026 18:51:12 -0400 Subject: [PATCH 1/3] fix move to specific custom layout --- .../data/CustomKeyboardLayout.kt | 15 +- .../database/KeyboardLayoutDao.kt | 130 +++++++- .../ui/KeyboardEditorViewModel.kt | 31 +- .../ui/KeyboardListFragment.kt | 58 +++- .../ui/KeyboardListViewModel.kt | 61 +++- .../database/AppDatabase.kt | 59 +++- .../ime_service/di/AppModule.kt | 2 + .../repository/KeyboardRepository.kt | 300 ++++++++++++++++-- app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../KeyboardRepositoryDeleteImpactTest.kt | 274 ++++++++++++++++ .../KeyboardRepositorySaveLayoutTest.kt | 294 +++++++++++++++++ 12 files changed, 1184 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/CustomKeyboardLayout.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/CustomKeyboardLayout.kt index 9bce0354a..63ddaaed1 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/CustomKeyboardLayout.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/CustomKeyboardLayout.kt @@ -1,13 +1,26 @@ package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import java.util.UUID /** * ユーザーが作成したキーボードレイアウトの全体設定を保存するエンティティ + * + * stableId は MoveToCustomKeyboard の永続参照 ID。layoutId とは別に管理し、 + * 編集保存・名前変更・キー変更などで決して書き換えてはいけない。 + * + * stableId に unique index を張ることで、同一の stableId を持つ row が DB 上で + * 複数存在しないことを保証する。これにより + * `KeyAction.MoveToCustomKeyboard(stableId)` の解決結果が一意になる。 */ -@Entity(tableName = "keyboard_layouts") +@Entity( + tableName = "keyboard_layouts", + indices = [ + Index(value = ["stableId"], unique = true) + ] +) data class CustomKeyboardLayout( @PrimaryKey(autoGenerate = true) val layoutId: Long = 0, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/KeyboardLayoutDao.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/KeyboardLayoutDao.kt index 5f4773cd4..200d01ee1 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/KeyboardLayoutDao.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/KeyboardLayoutDao.kt @@ -32,9 +32,119 @@ interface KeyboardLayoutDao { @Query("SELECT * FROM keyboard_layouts WHERE layoutId = :id") fun getFullLayoutById(id: Long): Flow + /** + * 既存レイアウトの identity (layoutId / stableId / createdAt / sortOrder) を維持したまま、 + * 子要素 (spacers / keys / flicks 系) だけをトランザクション内で再構築する。 + * + * 旧実装は parent row を delete + insert で作り直していたため、 + * stableId が再生成されて `KeyAction.MoveToCustomKeyboard` の参照が壊れていた。 + * このメソッドは parent row を一切 delete / replace しない。 + * + * - layout は @Update でフィールドのみ更新 (layoutId 維持・stableId 維持) + * - 子テーブル: spacer_definitions / key_definitions / flick_mappings 系を + * layoutId 単位で削除し、新しい内容を再 insert する + * - 例外発生時は @Transaction によって全てロールバックされる + */ + @Transaction + suspend fun updateFullKeyboardLayoutKeepingIdentity( + layout: CustomKeyboardLayout, + keys: List, + flicksMap: Map>, + circularFlicksMap: Map>, + twoStepFlicksMap: Map>, + longPressFlicksMap: Map>, + twoStepLongPressFlicksMap: Map>, + spacers: List = emptyList() + ) { + // 1) parent row は @Update で更新する。 + // layoutId / stableId / createdAt / sortOrder は呼び出し側 (Repository) が + // 既存値で埋めているはずだが、念のためここでも layoutId は維持される。 + updateLayout(layout) + + // 2) 子要素を全て layoutId 単位で削除する。 + // flick 系 → keys → spacers の順序は依存関係に合わせる。 + deleteKeysAndFlicksForLayout(layout.layoutId) + deleteSpacersForLayout(layout.layoutId) + + // 3) Spacers を再 insert (ownerLayoutId を上書き)。 + if (spacers.isNotEmpty()) { + insertSpacers(spacers.map { it.copy(spacerId = 0, ownerLayoutId = layout.layoutId) }) + } + + // 4) Keys を再 insert。新しい keyId が AUTOINCREMENT で採番される。 + val keysWithLayoutId = keys.map { + it.copy(keyId = 0, ownerLayoutId = layout.layoutId) + } + val newKeyIds = insertKeys(keysWithLayoutId) + + val identifierToIdMap = keysWithLayoutId + .mapIndexed { index, key -> key.keyIdentifier to newKeyIds[index] } + .toMap() + + // 5) flick / circular / two-step / long-press / two-step long-press を + // keyIdentifier 経由で新しい keyId にぶら下げ直して insert。 + val flicksWithRealKeyIds = mutableListOf() + identifierToIdMap.forEach { (identifier, realKeyId) -> + flicksMap[identifier]?.forEach { flick -> + flicksWithRealKeyIds.add(flick.copy(ownerKeyId = realKeyId)) + } + } + if (flicksWithRealKeyIds.isNotEmpty()) { + insertFlickMappings(flicksWithRealKeyIds) + } + + val circularFlicksWithRealKeyIds = mutableListOf() + identifierToIdMap.forEach { (identifier, realKeyId) -> + circularFlicksMap[identifier]?.forEach { flick -> + circularFlicksWithRealKeyIds.add(flick.copy(ownerKeyId = realKeyId)) + } + } + if (circularFlicksWithRealKeyIds.isNotEmpty()) { + insertCircularFlickMappings(circularFlicksWithRealKeyIds) + } + + val twoStepWithRealKeyIds = mutableListOf() + identifierToIdMap.forEach { (identifier, realKeyId) -> + twoStepFlicksMap[identifier]?.forEach { mapping -> + twoStepWithRealKeyIds.add(mapping.copy(ownerKeyId = realKeyId)) + } + } + if (twoStepWithRealKeyIds.isNotEmpty()) { + insertTwoStepFlickMappings(twoStepWithRealKeyIds) + } + + val longPressWithRealKeyIds = mutableListOf() + identifierToIdMap.forEach { (identifier, realKeyId) -> + longPressFlicksMap[identifier]?.forEach { mapping -> + longPressWithRealKeyIds.add(mapping.copy(ownerKeyId = realKeyId)) + } + } + if (longPressWithRealKeyIds.isNotEmpty()) { + insertLongPressFlickMappings(longPressWithRealKeyIds) + } + + val twoStepLongPressWithRealKeyIds = mutableListOf() + identifierToIdMap.forEach { (identifier, realKeyId) -> + twoStepLongPressFlicksMap[identifier]?.forEach { mapping -> + twoStepLongPressWithRealKeyIds.add(mapping.copy(ownerKeyId = realKeyId)) + } + } + if (twoStepLongPressWithRealKeyIds.isNotEmpty()) { + insertTwoStepLongPressFlickMappings(twoStepLongPressWithRealKeyIds) + } + } + /** * 新しいキーボードレイアウトをデータベースにアトミックに保存する。 * キーとフリック、TwoStep の正しい関連付けを保証する。 + * + * 注意: + * - これは「新規作成」専用と考えること。既存レイアウトの編集保存は + * [updateFullKeyboardLayoutKeepingIdentity] を使う。 + * - layout.layoutId が 0 のときは AUTOINCREMENT で採番される。 + * - layout.layoutId が >0 で、まだ存在しない id を強制したい場合のみ + * そのまま insert される (バックアップ復元など)。既存 id と衝突した場合は + * ABORT で例外が出るので、呼び出し側が適切に新規作成を選択すること。 */ @Transaction suspend fun insertFullKeyboardLayout( @@ -46,7 +156,7 @@ interface KeyboardLayoutDao { longPressFlicksMap: Map>, twoStepLongPressFlicksMap: Map>, spacers: List = emptyList() - ) { + ): Long { val layoutId = insertLayout(layout) if (spacers.isNotEmpty()) { @@ -109,9 +219,25 @@ interface KeyboardLayoutDao { if (twoStepLongPressWithRealKeyIds.isNotEmpty()) { insertTwoStepLongPressFlickMappings(twoStepLongPressWithRealKeyIds) } + + return layoutId } - @Insert(onConflict = OnConflictStrategy.REPLACE) + /** + * 親レイアウト ([CustomKeyboardLayout]) は他データから stableId 経由で参照される + * identity を持つため、conflict 時に row を置換してはいけない。 + * + * - 新規作成では layoutId=0 が渡され、Room によって AUTOINCREMENT で採番される。 + * - 既存更新は [updateLayout] (= @Update) を使うこと。 + * 既存更新で誤って [insertLayout] を呼ぶと、stableId / createdAt / sortOrder などの + * identity が壊れ、`KeyAction.MoveToCustomKeyboard(stableId)` の参照が + * 「削除済みのカスタムキーボード」になる。 + * + * 重複ガード: + * - 同じ layoutId を持つ row があれば失敗 (ABORT)。 + * - 同じ stableId を持つ row があれば unique index により失敗 (ABORT)。 + */ + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertLayout(layout: CustomKeyboardLayout): Long @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModel.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModel.kt index 75105f7e8..b4d1cf9eb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModel.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModel.kt @@ -139,22 +139,35 @@ class KeyboardEditorViewModel @Inject constructor( val nameExists = repository.doesNameExist(currentState.name, idToSave) if (nameExists) { _uiState.update { it.copy(duplicateNameError = true) } - } else { - if (idToSave != null) { - repository.deleteLayout(idToSave) - } + return@launch + } - val layoutToSave = currentState.layout.copy( - isRomaji = currentState.isRomaji, - isDirectMode = currentState.isDirectMode - ) + // 重要: 既存レイアウトを保存する場合でも、ここで親 (keyboard_layouts 行) を + // 一度 delete して作り直してはいけない。 + // 親 row を delete すると、stableId / sortOrder / createdAt の identity + // が失われ、MoveToCustomKeyboard(stableId) が「削除済みのカスタムキーボード」 + // として扱われる原因になる。 + // Repository 側で「新規作成」「既存更新」を分けるので、ViewModel は + // 現在の UI state を渡すだけでよい。 + val layoutToSave = currentState.layout.copy( + isRomaji = currentState.isRomaji, + isDirectMode = currentState.isDirectMode + ) + + val saveResult = runCatching { repository.saveLayout( layout = layoutToSave, name = currentState.name, id = idToSave ) - _uiState.update { it.copy(navigateBack = true) } } + saveResult + .onFailure { e -> + Timber.e(e, "saveLayout failed for id=%s", idToSave) + } + .onSuccess { + _uiState.update { it.copy(navigateBack = true) } + } } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListFragment.kt index 276d68bf8..dd136519e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListFragment.kt @@ -25,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutBackupImporter +import com.kazumaproject.markdownhelperkeyboard.repository.CustomKeyboardDeleteImpact import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportError import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportResult import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutJsonExporter @@ -160,6 +161,17 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { adapter.submitList(layouts) } } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.deleteEvents.collect { event -> + when (event) { + is KeyboardDeleteEvent.ConfirmReferenced -> + showReferencedDeleteWarningDialog(event.impact) + + is KeyboardDeleteEvent.Deleted -> Unit // RecyclerView は Flow から自動更新 + } + } + } } // [ADD] Function to set up the menu @@ -364,7 +376,51 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { dialog.dismiss() } .setPositiveButton(getString(com.kazumaproject.core.R.string.dialog_delete)) { _, _ -> - viewModel.deleteLayout(layoutId) + // 参照チェックは ViewModel.requestDeleteLayout 内で行われる。 + viewModel.requestDeleteLayout(layoutId) + } + .show() + } + + /** + * 削除対象が他キーから MoveToCustomKeyboard で参照されている場合に表示する警告ダイアログ。 + * ユーザーが了承した場合のみ実際の削除を実行する。 + */ + private fun showReferencedDeleteWarningDialog(impact: CustomKeyboardDeleteImpact) { + val ctx = context ?: return + val refCount = impact.references.size + val refSummary = impact.references + .take(5) + .joinToString(separator = "\n") { ref -> + val keyDesc = ref.sourceKeyLabel + ?.takeIf { it.isNotBlank() } + ?: ref.sourceKeyIdentifier + "・${ref.sourceLayoutName} / $keyDesc" + } + val omitted = if (refCount > 5) "\n… 他 ${refCount - 5} 件" else "" + + val message = buildString { + append( + getString( + R.string.delete_layout_with_references_message, + refCount + ) + ) + if (refSummary.isNotBlank()) { + append("\n\n") + append(refSummary) + append(omitted) + } + } + + MaterialAlertDialogBuilder(ctx) + .setTitle(getString(R.string.delete_layout_with_references_title)) + .setMessage(message) + .setNegativeButton(getString(com.kazumaproject.core.R.string.dialog_cancel)) { dialog, _ -> + dialog.dismiss() + } + .setPositiveButton(getString(com.kazumaproject.core.R.string.dialog_delete)) { _, _ -> + viewModel.confirmDeleteWithReferences(impact.layoutId) } .show() } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListViewModel.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListViewModel.kt index 7e59ffff6..016ec1731 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListViewModel.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardListViewModel.kt @@ -3,14 +3,30 @@ package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.repository.CustomKeyboardDeleteImpact import com.kazumaproject.markdownhelperkeyboard.repository.KeyboardRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject +/** + * 削除フローで UI に通知するイベント。 + * + * - [ConfirmReferenced] : 削除対象が他キーから参照されている。警告ダイアログを出す。 + * - [Deleted] : 削除完了。 + */ +sealed class KeyboardDeleteEvent { + data class ConfirmReferenced(val impact: CustomKeyboardDeleteImpact) : KeyboardDeleteEvent() + data class Deleted(val layoutId: Long) : KeyboardDeleteEvent() +} + @HiltViewModel class KeyboardListViewModel @Inject constructor( private val repository: KeyboardRepository @@ -23,9 +39,52 @@ class KeyboardListViewModel @Inject constructor( initialValue = emptyList() ) + private val _deleteEvents = Channel(Channel.BUFFERED) + val deleteEvents: Flow = _deleteEvents.receiveAsFlow() + + /** + * ユーザーが削除ボタンを押したときの起点。 + * - 参照が無ければそのまま削除し、[KeyboardDeleteEvent.Deleted] を流す。 + * - 参照があれば [KeyboardDeleteEvent.ConfirmReferenced] を流して、UI に + * 警告ダイアログを表示させる。ユーザーが了承した場合のみ + * [confirmDeleteWithReferences] が呼ばれる。 + */ + fun requestDeleteLayout(id: Long) { + viewModelScope.launch { + val impact = runCatching { repository.getDeleteImpactForLayout(id) } + .onFailure { Timber.e(it, "getDeleteImpactForLayout(%s) failed", id) } + .getOrNull() + if (impact == null || !impact.hasReferences) { + repository.deleteLayoutConfirmed(id) + _deleteEvents.send(KeyboardDeleteEvent.Deleted(id)) + } else { + Timber.w( + "requestDeleteLayout: layoutId=%s has %s MoveToCustomKeyboard references; awaiting user confirmation", + id, impact.references.size + ) + _deleteEvents.send(KeyboardDeleteEvent.ConfirmReferenced(impact)) + } + } + } + + /** + * 警告ダイアログでユーザーが「それでも削除」を選んだあとに呼ぶ。 + */ + fun confirmDeleteWithReferences(id: Long) { + viewModelScope.launch { + repository.deleteLayoutConfirmed(id) + _deleteEvents.send(KeyboardDeleteEvent.Deleted(id)) + } + } + + /** + * @deprecated 参照チェックを行わず無条件に削除する。互換のために残しているが、 + * 通常は [requestDeleteLayout] を使うこと。 + */ + @Deprecated("Use requestDeleteLayout to enforce reference check.") fun deleteLayout(id: Long) { viewModelScope.launch { - repository.deleteLayout(id) + repository.deleteLayoutConfirmed(id) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt index 138ecfa1b..6d38d51ad 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt @@ -70,7 +70,7 @@ import com.kazumaproject.markdownhelperkeyboard.user_template.database.UserTempl PhysicalKeyboardShortcutItem::class, SpacerDefinition::class, ], - version = 30, + version = 31, exportSchema = false ) @TypeConverters( @@ -753,5 +753,62 @@ abstract class AppDatabase : RoomDatabase() { ) } } + + /** + * バージョン30から31へのマイグレーション。 + * + * KeyAction.MoveToCustomKeyboard が参照する CustomKeyboardLayout.stableId に + * unique index を張る。インデックスを張る前に既存データを修復する: + * + * 1. blank / null 相当の stableId に決定的なユニーク値を割り当てる + * (`auto-stable-{layoutId}` 形式)。既存ユーザーの DB に残った旧データを + * 壊さないように、UUID ではなく layoutId 由来の値を使う。 + * 2. 重複する stableId を持つ row のうち、layoutId が最小の row 以外には + * `auto-stable-dup-{layoutId}` を割り当てて衝突を解消する。 + * (旧バージョンに stableId が空のまま保存されていた場合や、 + * バックアップのインポートで重複が混入したケースを想定) + * 3. unique index を作成する。 + * + * このマイグレーションは破壊的に layoutId / 既存の有効な stableId を上書きしない。 + * 修復処理によって stableId が変わった場合、その row を参照していた + * MoveToCustomKeyboard は「削除済みのカスタムキーボード」と表示されるが、 + * これは元から不整合な状態だったレイアウトに限られる。正常なレイアウトの + * stableId は維持される。 + */ + val MIGRATION_30_31 = object : Migration(30, 31) { + override fun migrate(db: SupportSQLiteDatabase) { + // 1) blank / null stableId を決定的に埋める + db.execSQL( + """ + UPDATE keyboard_layouts + SET stableId = 'auto-stable-' || layoutId + WHERE stableId IS NULL OR stableId = '' + """.trimIndent() + ) + + // 2) 重複する stableId を解消する + // layoutId が最小のものを「残す」候補とし、それ以外には + // `auto-stable-dup-` を割り当てる。 + db.execSQL( + """ + UPDATE keyboard_layouts + SET stableId = 'auto-stable-dup-' || layoutId + WHERE layoutId NOT IN ( + SELECT MIN(layoutId) + FROM keyboard_layouts + GROUP BY stableId + ) + """.trimIndent() + ) + + // 3) unique index 作成 + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS `index_keyboard_layouts_stableId` + ON `keyboard_layouts`(`stableId`) + """.trimIndent() + ) + } + } } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt index 5efc9108f..6cde41643 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt @@ -39,6 +39,7 @@ import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.M import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_27_28 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_28_29 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_29_30 +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_30_31 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_2_3 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_3_4 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_4_5 @@ -120,6 +121,7 @@ object AppModule { MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, + MIGRATION_30_31, ) .build() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt index 044d4b29a..5c492818e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt @@ -50,6 +50,41 @@ private data class DbKeyboardLayoutParts( val spacers: List = emptyList() ) +/** + * `saveLayout(id = X)` で渡された X が DB 上に存在しなかった場合の例外。 + * + * 旧実装は黙って新規作成にフォールバックして stableId を再生成していたため、 + * MoveToCustomKeyboard の参照が壊れる原因になっていた。新実装では明示的に + * 例外を投げ、ViewModel 側で握り潰すかリカバリーするかを選択させる。 + */ +class LayoutNotFoundException(val layoutId: Long) : NoSuchElementException( + "CustomKeyboardLayout(layoutId=$layoutId) does not exist; cannot update." +) + +/** + * MoveToCustomKeyboard が参照している場所を表す。 + */ +data class MoveToCustomKeyboardReference( + val sourceLayoutId: Long, + val sourceLayoutName: String, + val sourceKeyIdentifier: String, + val sourceKeyLabel: String?, + val targetStableId: String +) + +/** + * 削除対象レイアウトの参照状況を表す。 + */ +data class CustomKeyboardDeleteImpact( + val layoutId: Long, + val layoutName: String, + val stableId: String, + val references: List +) { + val hasReferences: Boolean + get() = references.isNotEmpty() +} + fun ensureStableIdsForLayouts( layouts: List, generateStableId: () -> String = { UUID.randomUUID().toString() } @@ -278,49 +313,46 @@ class KeyboardRepository @Inject constructor( // ----------------------------- /** - * レイアウトの保存処理(TWO_STEP_FLICK 含む) + * レイアウトの保存処理(TWO_STEP_FLICK 含む)。 * - * 重要: - * - 既存レイアウトの createdAt / sortOrder を維持する(編集で順序が壊れないように) - * - 新規作成は sortOrder = max+1 として最上位に追加 + * 設計: + * - id == null / id <= 0 → 新規作成 ([createNewLayoutInternal]): + * stableId を新規 UUID で生成し、createdAt は現在時刻、sortOrder は max+1。 + * - id > 0 → 既存更新 ([updateExistingLayoutInternal]): + * 既存レイアウトを取得し、layoutId / stableId / createdAt / sortOrder を維持。 + * name / rowCount / columnCount / isRomaji / isDirectMode のみ更新。 + * 子要素は DAO のトランザクション内で再構築。 + * 既存が見つからない場合は [LayoutNotFoundException] を投げる + * (黙って新規作成にフォールバックして stableId を再生成しないため)。 * - * 注意: - * - dao.insertLayout() が REPLACE の場合、既存保存時は親(row)が置換されます。 - * ただし layoutId を明示しているので ID は維持されます。 - * また FK の ON DELETE CASCADE がある場合、子テーブルは置換に伴い一度削除され、 - * 直後に insertFullKeyboardLayout 内で再挿入される想定です。 + * @return 保存後の layoutId */ - suspend fun saveLayout(layout: KeyboardLayout, name: String, id: Long?) { - Timber.d("saveLayout: $layout") + suspend fun saveLayout(layout: KeyboardLayout, name: String, id: Long?): Long { + Timber.d("saveLayout: id=%s name=%s", id, name) - val existing = if (id != null && id > 0) { - dao.getFullLayoutOneShot(id)?.layout + return if (id == null || id <= 0L) { + createNewLayoutInternal(layout, name) } else { - null + updateExistingLayoutInternal(id, layout, name) } + } - val createdAtToKeep = existing?.createdAt ?: System.currentTimeMillis() - val sortOrderToKeep = existing?.sortOrder ?: nextTopSortOrder() - val stableIdToKeep = existing?.stableId - ?.takeIf { it.isNotBlank() } - ?: UUID.randomUUID().toString() - - val dbLayout = CustomKeyboardLayout( - layoutId = id ?: 0, + private suspend fun createNewLayoutInternal(layout: KeyboardLayout, name: String): Long { + val newStableId = generateUniqueStableId() + val newLayout = CustomKeyboardLayout( + layoutId = 0, name = name, columnCount = layout.columnCount, rowCount = layout.rowCount, isRomaji = layout.isRomaji, isDirectMode = layout.isDirectMode, - createdAt = createdAtToKeep, - sortOrder = sortOrderToKeep, - stableId = stableIdToKeep + createdAt = System.currentTimeMillis(), + sortOrder = nextTopSortOrder(), + stableId = newStableId ) - val parts = convertToDbModel(layout) - - dao.insertFullKeyboardLayout( - dbLayout, + val newLayoutId = dao.insertFullKeyboardLayout( + newLayout, parts.keys, parts.flicksMap, parts.circularFlicksMap, @@ -329,12 +361,222 @@ class KeyboardRepository @Inject constructor( parts.twoStepLongPressMap, parts.spacers ) + Timber.d("createNewLayoutInternal: inserted layoutId=%s stableId=%s", newLayoutId, newStableId) + return newLayoutId + } + + private suspend fun updateExistingLayoutInternal( + layoutId: Long, + layout: KeyboardLayout, + name: String + ): Long { + val existing = dao.getFullLayoutOneShot(layoutId)?.layout + ?: throw LayoutNotFoundException(layoutId).also { + Timber.e("updateExistingLayoutInternal: layoutId=%s not found", layoutId) + } + + // identity (layoutId / stableId / createdAt / sortOrder) は決して書き換えない。 + // stableId が空の既存 row が来た場合は (旧データ) 新しい UUID を割り当てておく。 + // これは "blank → 何かしらの stableId" への一方向の修復であり、 + // 既存の有効な stableId は絶対に変更しない。 + val repairedStableId = if (existing.stableId.isBlank()) { + generateUniqueStableId() + } else { + existing.stableId + } + + val updatedParent = existing.copy( + name = name, + columnCount = layout.columnCount, + rowCount = layout.rowCount, + isRomaji = layout.isRomaji, + isDirectMode = layout.isDirectMode, + stableId = repairedStableId + ) + + val parts = convertToDbModel(layout) + dao.updateFullKeyboardLayoutKeepingIdentity( + layout = updatedParent, + keys = parts.keys, + flicksMap = parts.flicksMap, + circularFlicksMap = parts.circularFlicksMap, + twoStepFlicksMap = parts.twoStepMap, + longPressFlicksMap = parts.longPressFlicksMap, + twoStepLongPressFlicksMap = parts.twoStepLongPressMap, + spacers = parts.spacers + ) + Timber.d( + "updateExistingLayoutInternal: kept identity layoutId=%s stableId=%s", + updatedParent.layoutId, updatedParent.stableId + ) + return updatedParent.layoutId + } + + /** + * 既存の stableId と衝突しない UUID を生成する。 + * unique index 違反による insert 失敗を防ぐためのガード。 + */ + private suspend fun generateUniqueStableId(): String { + repeat(10) { + val candidate = UUID.randomUUID().toString() + if (dao.findLayoutByStableId(candidate) == null) { + return candidate + } + } + // ここまで来る確率は事実上ゼロ (UUIDv4 は 2^122 通り)。最後の保険として + // タイムスタンプ付きの値を返す。 + return "fallback-stable-${System.nanoTime()}-${UUID.randomUUID()}" } + /** + * UI からの「とりあえず削除して」を受け付ける従来 API。 + * + * 通常は [deleteLayoutConfirmed] を使うこと。これは参照チェック後に呼び出される + * 内部 API としても利用される。 + */ suspend fun deleteLayout(id: Long) { + Timber.d("deleteLayout: id=%s", id) + dao.deleteLayout(id) + } + + /** + * 削除前に必ず参照チェックを行うことを呼び出し側に明示するためのラッパー。 + * 参照の有無に関わらず削除を実行する。 + * + * UI 層は事前に [getDeleteImpactForLayout] を呼び、 + * 参照ありなら警告ダイアログでユーザーの了承を取り、 + * 了承後にこのメソッドを呼ぶ。 + */ + suspend fun deleteLayoutConfirmed(id: Long) { + Timber.d("deleteLayoutConfirmed: id=%s", id) dao.deleteLayout(id) } + // ----------------------------- + // MoveToCustomKeyboard 参照検査 + // ----------------------------- + + /** + * 指定 layoutId を削除した場合に「削除済みのカスタムキーボード」になる + * MoveToCustomKeyboard の参照一覧を返す。 + * + * - 削除対象が存在しない場合は references=空、layoutName=空文字、stableId=空文字。 + * - stableId が空の場合は参照が無いとみなす (MoveToCustomKeyboard("") はそもそも + * 永続化時点で破棄される設計のため)。 + */ + suspend fun getDeleteImpactForLayout(layoutId: Long): CustomKeyboardDeleteImpact { + val target = dao.getFullLayoutOneShot(layoutId)?.layout + if (target == null) { + return CustomKeyboardDeleteImpact( + layoutId = layoutId, + layoutName = "", + stableId = "", + references = emptyList() + ) + } + val references = if (target.stableId.isBlank()) { + emptyList() + } else { + findMoveToCustomKeyboardReferences(target.stableId) + } + return CustomKeyboardDeleteImpact( + layoutId = target.layoutId, + layoutName = target.name, + stableId = target.stableId, + references = references + ) + } + + /** + * 全カスタムキーボードを走査し、`MoveToCustomKeyboard(targetStableId)` に + * 該当する参照箇所を集める。tap action / petal flick / circular flick / + * two-step flick / long-press flick / two-step long-press flick のすべてを対象。 + * + * 既存設計に合わせ、DB 文字列ではなく + * [com.kazumaproject.custom_keyboard.data.KeyActionMapper.toKeyAction] + * と [toFlickAction]/[FlickMapping/CircularFlickMapping#toFlickAction] を通して + * KeyAction として復元してから判定する。これにより、 + * 文字列表現の揺れ (`MoveToCustomKeyboard:xxx` / `MoveToCustomKeyboard` の + * actionType=stableId) の両形式を取りこぼさない。 + */ + suspend fun findMoveToCustomKeyboardReferences( + targetStableId: String + ): List { + if (targetStableId.isBlank()) return emptyList() + + val references = mutableListOf() + val allLayouts = dao.getAllFullLayoutsOneShot() + + for (full in allLayouts) { + val sourceLayoutId = full.layout.layoutId + val sourceLayoutName = full.layout.name + + for (keyWithFlicks in full.keysWithFlicks) { + val key = keyWithFlicks.key + val keyIdentifier = key.keyIdentifier + val keyLabel = key.label.takeIf { it.isNotBlank() } + + // 1) tap action + val tapAction = KeyActionMapper.toKeyAction(key.action) + if (tapAction is KeyAction.MoveToCustomKeyboard && + tapAction.stableId == targetStableId + ) { + references += MoveToCustomKeyboardReference( + sourceLayoutId = sourceLayoutId, + sourceLayoutName = sourceLayoutName, + sourceKeyIdentifier = keyIdentifier, + sourceKeyLabel = keyLabel, + targetStableId = targetStableId + ) + } + + // 2) flick mapping + for (flick in keyWithFlicks.flicks) { + val act = flick.toFlickAction() + if (act is FlickAction.Action) { + val a = act.action + if (a is KeyAction.MoveToCustomKeyboard && + a.stableId == targetStableId + ) { + references += MoveToCustomKeyboardReference( + sourceLayoutId = sourceLayoutId, + sourceLayoutName = sourceLayoutName, + sourceKeyIdentifier = keyIdentifier, + sourceKeyLabel = keyLabel, + targetStableId = targetStableId + ) + } + } + } + + // 3) circular flick mapping + for (cflick in keyWithFlicks.circularFlicks) { + val act = cflick.toFlickAction() + if (act is FlickAction.Action) { + val a = act.action + if (a is KeyAction.MoveToCustomKeyboard && + a.stableId == targetStableId + ) { + references += MoveToCustomKeyboardReference( + sourceLayoutId = sourceLayoutId, + sourceLayoutName = sourceLayoutName, + sourceKeyIdentifier = keyIdentifier, + sourceKeyLabel = keyLabel, + targetStableId = targetStableId + ) + } + } + } + + // two-step / long-press / two-step long-press は + // 出力テキスト (String) を保持する設計なので、KeyAction を + // 持つことはなく MoveToCustomKeyboard 参照は発生しない。 + // 将来的に actionType を持つ拡張があった場合はここに追加する。 + } + } + return references + } + /** * レイアウト複製: * - 名前衝突回避 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 74d79b71c..ad8d8e3b4 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -209,6 +209,8 @@ カスタムキーボードを開いたとき、最初のカスタムキーボードを表示します 移動先カスタムキーボード 削除済みのカスタムキーボード + 参照中のレイアウトを削除しますか? + このカスタムキーボードは %1$d 個のキーから参照されています。削除すると、参照しているキーは「削除済みのカスタムキーボード」と表示され、切り替えできなくなります。それでも削除しますか? クリップボードの履歴 クリップボードの履歴を表示 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c448ad8e..7ec54f7fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,8 @@ Shows the first custom keyboard when opening custom keyboards Target custom keyboard Deleted custom keyboard + Delete layout in use? + This custom keyboard is referenced by %1$d key(s). If you delete it, those keys will show \"Deleted custom keyboard\" and can no longer switch to it. Delete anyway? Clipboard History Show clipboard history diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt new file mode 100644 index 000000000..04ba7322a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt @@ -0,0 +1,274 @@ +package com.kazumaproject.markdownhelperkeyboard.repository + +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CircularFlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyWithFlicks +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database.KeyboardLayoutDao +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * `KeyboardRepository.getDeleteImpactForLayout` / `findMoveToCustomKeyboardReferences` の + * 振る舞いをロックする。 + * + * - F: 参照ありを正しく検出 + * - G: 参照なしを正しく検出 + * - flick / circular flick の MoveToCustomKeyboard 参照も検出 + */ +class KeyboardRepositoryDeleteImpactTest { + + private val dao: KeyboardLayoutDao = mock() + private val repository = KeyboardRepository(dao) + + @Test + fun getDeleteImpactForLayout_referencedByTapAction_isDetected() = runBlocking { + val targetStableId = "target-stable" + val target = layout(layoutId = 10, stableId = targetStableId, name = "Target") + val source = layout(layoutId = 20, stableId = "source-stable", name = "Source") + + val sourceFull = FullKeyboardLayout( + layout = source, + keysWithFlicks = listOf( + keyWithMoveToCustomKeyboardTapAction( + keyId = 200, + keyIdentifier = "key-source-1", + label = "→A", + targetStableId = targetStableId + ) + ), + spacers = emptyList() + ) + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + + whenever(dao.getFullLayoutOneShot(10L)).thenReturn(targetFull) + whenever(dao.getAllFullLayoutsOneShot()).thenReturn(listOf(targetFull, sourceFull)) + + val impact = repository.getDeleteImpactForLayout(10L) + assertTrue(impact.hasReferences) + assertEquals(1, impact.references.size) + val ref = impact.references.single() + assertEquals(20L, ref.sourceLayoutId) + assertEquals("Source", ref.sourceLayoutName) + assertEquals("key-source-1", ref.sourceKeyIdentifier) + assertEquals("→A", ref.sourceKeyLabel) + assertEquals(targetStableId, ref.targetStableId) + } + + @Test + fun getDeleteImpactForLayout_referencedByFlickMapping_isDetected() = runBlocking { + val targetStableId = "target-via-flick" + val target = layout(layoutId = 11, stableId = targetStableId, name = "Target") + val source = layout(layoutId = 21, stableId = "source-stable", name = "Source") + + val sourceFull = FullKeyboardLayout( + layout = source, + keysWithFlicks = listOf( + KeyWithFlicks( + key = KeyDefinition( + keyId = 210, + ownerLayoutId = 21, + label = "X", + row = 0, + column = 0, + keyType = KeyType.PETAL_FLICK, + isSpecialKey = false, + keyIdentifier = "key-source-flick", + action = null + ), + flicks = listOf( + FlickMapping( + ownerKeyId = 210, + stateIndex = 0, + flickDirection = FlickDirection.UP, + actionType = "MoveToCustomKeyboard", + actionValue = targetStableId + ) + ), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + ), + spacers = emptyList() + ) + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + + whenever(dao.getFullLayoutOneShot(11L)).thenReturn(targetFull) + whenever(dao.getAllFullLayoutsOneShot()).thenReturn(listOf(targetFull, sourceFull)) + + val impact = repository.getDeleteImpactForLayout(11L) + assertTrue(impact.hasReferences) + assertEquals("key-source-flick", impact.references.single().sourceKeyIdentifier) + } + + @Test + fun getDeleteImpactForLayout_referencedByCircularFlick_isDetected() = runBlocking { + val targetStableId = "target-via-circular" + val target = layout(layoutId = 12, stableId = targetStableId, name = "Target") + val source = layout(layoutId = 22, stableId = "source-stable", name = "Source") + + val sourceFull = FullKeyboardLayout( + layout = source, + keysWithFlicks = listOf( + KeyWithFlicks( + key = KeyDefinition( + keyId = 220, + ownerLayoutId = 22, + label = "Y", + row = 0, + column = 0, + keyType = KeyType.CIRCULAR_FLICK, + isSpecialKey = false, + keyIdentifier = "key-source-circ", + action = null + ), + flicks = emptyList(), + circularFlicks = listOf( + CircularFlickMapping( + ownerKeyId = 220, + stateIndex = 0, + circularDirection = + com.kazumaproject.custom_keyboard.data.CircularFlickDirection.SLOT_0, + actionType = "MoveToCustomKeyboard", + actionValue = targetStableId + ) + ), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + ), + spacers = emptyList() + ) + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + + whenever(dao.getFullLayoutOneShot(12L)).thenReturn(targetFull) + whenever(dao.getAllFullLayoutsOneShot()).thenReturn(listOf(targetFull, sourceFull)) + + val impact = repository.getDeleteImpactForLayout(12L) + assertTrue(impact.hasReferences) + assertEquals("key-source-circ", impact.references.single().sourceKeyIdentifier) + } + + @Test + fun getDeleteImpactForLayout_noReferences_returnsEmpty() = runBlocking { + val target = layout(layoutId = 13, stableId = "target-no-ref", name = "Target") + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + val unrelated = FullKeyboardLayout( + layout = layout(layoutId = 23, stableId = "stable-unrelated", name = "Unrelated"), + keysWithFlicks = listOf( + keyWithTextAction( + keyId = 230, + keyIdentifier = "k1", + label = "あ" + ) + ), + spacers = emptyList() + ) + + whenever(dao.getFullLayoutOneShot(13L)).thenReturn(targetFull) + whenever(dao.getAllFullLayoutsOneShot()).thenReturn(listOf(targetFull, unrelated)) + + val impact = repository.getDeleteImpactForLayout(13L) + assertFalse(impact.hasReferences) + assertEquals("Target", impact.layoutName) + assertEquals("target-no-ref", impact.stableId) + } + + @Test + fun getDeleteImpactForLayout_targetWithBlankStableId_doesNotScan() = runBlocking { + val target = layout(layoutId = 14, stableId = "", name = "BadTarget") + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + whenever(dao.getFullLayoutOneShot(14L)).thenReturn(targetFull) + + val impact = repository.getDeleteImpactForLayout(14L) + assertFalse(impact.hasReferences) + } + + @Test + fun getDeleteImpactForLayout_layoutNotFound_returnsEmptyImpact() = runBlocking { + whenever(dao.getFullLayoutOneShot(999L)).thenReturn(null) + + val impact = repository.getDeleteImpactForLayout(999L) + assertFalse(impact.hasReferences) + assertEquals(999L, impact.layoutId) + assertEquals("", impact.stableId) + } + + // --------------------------------------------------------- + // helpers + // --------------------------------------------------------- + + private fun layout(layoutId: Long, stableId: String, name: String): CustomKeyboardLayout { + return CustomKeyboardLayout( + layoutId = layoutId, + name = name, + columnCount = 5, + rowCount = 4, + stableId = stableId + ) + } + + private fun keyWithMoveToCustomKeyboardTapAction( + keyId: Long, + keyIdentifier: String, + label: String, + targetStableId: String + ): KeyWithFlicks { + return KeyWithFlicks( + key = KeyDefinition( + keyId = keyId, + ownerLayoutId = 0, + label = label, + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = true, + keyIdentifier = keyIdentifier, + // KeyActionMapper の MOVE_TO_CUSTOM_KEYBOARD_PREFIX に合わせる + action = "MoveToCustomKeyboard:$targetStableId" + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + } + + private fun keyWithTextAction( + keyId: Long, + keyIdentifier: String, + label: String + ): KeyWithFlicks { + return KeyWithFlicks( + key = KeyDefinition( + keyId = keyId, + ownerLayoutId = 0, + label = label, + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyIdentifier = keyIdentifier, + action = "Text:$label" + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt new file mode 100644 index 000000000..dbf3caaef --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt @@ -0,0 +1,294 @@ +package com.kazumaproject.markdownhelperkeyboard.repository + +import com.kazumaproject.custom_keyboard.data.FlickAction +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CircularFlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyWithFlicks +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.LongPressFlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.SpacerDefinition +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.TwoStepFlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.TwoStepLongPressMappingEntity +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database.KeyboardLayoutDao +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * KeyboardRepository.saveLayout の長期的な不変条件をロックする回帰テスト。 + * + * このテストが守るのは以下の性質。これが壊れると + * `KeyAction.MoveToCustomKeyboard(stableId)` が「削除済みのカスタムキーボード」になる。 + * + * - 既存レイアウト保存で stableId が変わらない + * - 既存レイアウト保存で createdAt / sortOrder / layoutId が変わらない + * - 既存レイアウト保存で親 (keyboard_layouts) row は delete されず @Update される + * - 既存更新対象が DB に無い場合は黙って新規作成にフォールバックしない + * (= 新しい stableId を勝手に割り当てない) + */ +class KeyboardRepositorySaveLayoutTest { + + private val dao: KeyboardLayoutDao = mock() + private val repository = KeyboardRepository(dao) + + // --------------------------------------------------------- + // A. 既存レイアウト保存で stableId が変わらない + // E. parent identity 維持 + // --------------------------------------------------------- + @Test + fun saveLayout_existingId_keepsStableIdAndIdentity() = runBlocking { + val existingStableId = "stable-a" + val existingCreatedAt = 1_700_000_000_000L + val existingSortOrder = 7 + + whenever(dao.getFullLayoutOneShot(1L)).thenReturn( + fullLayout( + layoutId = 1, + stableId = existingStableId, + createdAt = existingCreatedAt, + sortOrder = existingSortOrder, + name = "OldName" + ) + ) + + val ui = simpleLayout(columns = 5, rows = 4) + repository.saveLayout(ui, name = "NewName", id = 1L) + + // updateFullKeyboardLayoutKeepingIdentity が呼ばれ、 + // identity 4 値が維持されていることを検証。 + val parentCaptor = argumentCaptor() + verify(dao).updateFullKeyboardLayoutKeepingIdentity( + parentCaptor.capture(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + + val saved = parentCaptor.firstValue + assertEquals(1L, saved.layoutId) + assertEquals(existingStableId, saved.stableId) + assertEquals(existingCreatedAt, saved.createdAt) + assertEquals(existingSortOrder, saved.sortOrder) + // 編集可能フィールドは更新される + assertEquals("NewName", saved.name) + + // 親レイアウトを delete しない + verify(dao, never()).deleteLayout(any()) + // 既存更新では insertFullKeyboardLayout は呼ばれない (= delete + insert で identity を壊さない) + verify(dao, never()).insertFullKeyboardLayout( + any(), any(), any(), any(), any(), any(), any(), any() + ) + } + + // --------------------------------------------------------- + // B. 既存レイアウト保存で sortOrder が変わらない (E と同じケースで保証) + // --------------------------------------------------------- + @Test + fun saveLayout_existingId_doesNotMoveLayoutToTopOfList() = runBlocking { + whenever(dao.getFullLayoutOneShot(2L)).thenReturn( + fullLayout( + layoutId = 2, + stableId = "stable-b", + createdAt = 1_700_000_000_000L, + sortOrder = 2, + name = "B" + ) + ) + + repository.saveLayout(simpleLayout(), name = "B", id = 2L) + + val parentCaptor = argumentCaptor() + verify(dao).updateFullKeyboardLayoutKeepingIdentity( + parentCaptor.capture(), + any(), any(), any(), any(), any(), any(), any() + ) + // nextTopSortOrder() で再採番されたら 3 などに変わる。 + // identity 維持なので 2 のまま。 + assertEquals(2, parentCaptor.firstValue.sortOrder) + } + + // --------------------------------------------------------- + // C. MoveToCustomKeyboard が保存後も有効 + // --------------------------------------------------------- + @Test + fun saveLayout_targetLayoutEdit_preservesStableIdSoMoveToCustomKeyboardStaysValid() = runBlocking { + val targetStableId = "target-stable" + whenever(dao.getFullLayoutOneShot(10L)).thenReturn( + fullLayout( + layoutId = 10, + stableId = targetStableId, + createdAt = 1L, + sortOrder = 1, + name = "Target" + ) + ) + + repository.saveLayout(simpleLayout(), name = "Target", id = 10L) + + val parentCaptor = argumentCaptor() + verify(dao).updateFullKeyboardLayoutKeepingIdentity( + parentCaptor.capture(), + any(), any(), any(), any(), any(), any(), any() + ) + // Source 側の MoveToCustomKeyboard("target-stable") を解決するためには + // Target.stableId が "target-stable" のままでなければならない。 + assertEquals(targetStableId, parentCaptor.firstValue.stableId) + } + + // --------------------------------------------------------- + // I. 既存更新対象が存在しない場合 + // --------------------------------------------------------- + @Test + fun saveLayout_existingIdNotFound_throwsLayoutNotFoundException() = runBlocking { + whenever(dao.getFullLayoutOneShot(999L)).thenReturn(null) + + try { + repository.saveLayout(simpleLayout(), name = "ghost", id = 999L) + fail("Expected LayoutNotFoundException") + } catch (e: LayoutNotFoundException) { + assertEquals(999L, e.layoutId) + } + + // 黙って新規作成にフォールバックしないことを検証 + verify(dao, never()).insertFullKeyboardLayout( + any(), any(), any(), any(), any(), any(), any(), any() + ) + verify(dao, never()).updateFullKeyboardLayoutKeepingIdentity( + any(), any(), any(), any(), any(), any(), any(), any() + ) + } + + // --------------------------------------------------------- + // 新規作成 (id == null) で stableId は新規生成され、insertFullKeyboardLayout が呼ばれる + // --------------------------------------------------------- + @Test + fun saveLayout_newLayout_generatesUniqueStableIdAndInserts() = runBlocking { + whenever(dao.getMaxSortOrder()).thenReturn(3) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + whenever( + dao.insertFullKeyboardLayout( + any(), any(), any(), any(), any(), any(), any(), any() + ) + ).thenReturn(42L) + + val newLayoutId = repository.saveLayout(simpleLayout(), name = "fresh", id = null) + assertEquals(42L, newLayoutId) + + val parentCaptor = argumentCaptor() + verify(dao).insertFullKeyboardLayout( + parentCaptor.capture(), + any(), any(), any(), any(), any(), any(), any() + ) + val inserted = parentCaptor.firstValue + assertEquals(0L, inserted.layoutId) // AUTOINCREMENT 用 + assertTrue("stableId must be non-blank for new layout", inserted.stableId.isNotBlank()) + assertEquals(4, inserted.sortOrder) // max(3) + 1 + // 既存更新は呼ばれない + verify(dao, never()).updateFullKeyboardLayoutKeepingIdentity( + any(), any(), any(), any(), any(), any(), any(), any() + ) + } + + @Test + fun saveLayout_existingIdWithBlankStableId_repairsStableIdButNeverChangesValidOne() = runBlocking { + // 旧データで stableId が blank だった既存 row。今回の保存で blank → 新 UUID に修復される。 + whenever(dao.getFullLayoutOneShot(5L)).thenReturn( + fullLayout( + layoutId = 5, + stableId = "", + createdAt = 100L, + sortOrder = 1, + name = "blank-id" + ) + ) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + + repository.saveLayout(simpleLayout(), name = "blank-id", id = 5L) + + val parentCaptor = argumentCaptor() + verify(dao).updateFullKeyboardLayoutKeepingIdentity( + parentCaptor.capture(), + any(), any(), any(), any(), any(), any(), any() + ) + val saved = parentCaptor.firstValue + // blank だった stableId は埋められる + assertNotEquals("", saved.stableId) + assertNotNull(saved.stableId) + // identity の他の値は維持 + assertEquals(5L, saved.layoutId) + assertEquals(100L, saved.createdAt) + assertEquals(1, saved.sortOrder) + } + + // --------------------------------------------------------- + // helpers + // --------------------------------------------------------- + + private fun simpleLayout(columns: Int = 5, rows: Int = 4): KeyboardLayout { + // 1 個だけのシンプルなキー。convertToDbModel が走るのに最低限必要な状態。 + val key = KeyData( + label = "あ", + row = 0, + column = 0, + isFlickable = false, + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyId = "key-1", + action = KeyAction.Text("あ") + ) + return KeyboardLayout( + keys = listOf(key), + flickKeyMaps = mapOf( + "key-1" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("あ"))) + ), + columnCount = columns, + rowCount = rows + ) + } + + private fun fullLayout( + layoutId: Long, + stableId: String, + createdAt: Long, + sortOrder: Int, + name: String + ): FullKeyboardLayout { + return FullKeyboardLayout( + layout = CustomKeyboardLayout( + layoutId = layoutId, + name = name, + columnCount = 5, + rowCount = 4, + isRomaji = false, + isDirectMode = false, + createdAt = createdAt, + sortOrder = sortOrder, + stableId = stableId + ), + keysWithFlicks = emptyList(), + spacers = emptyList() + ) + } +} From 1818ae6420bf18e850f6a4d84b0e07c0fdaa4451 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 4 May 2026 18:52:27 -0400 Subject: [PATCH 2/3] update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b698175b7..25f6ce8aa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 746 - versionName "1.7.52" + versionCode 747 + versionName "1.7.53" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } From 975be637e4396bea3b2fafb4b1bdda72aef43f46 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 4 May 2026 19:21:36 -0400 Subject: [PATCH 3/3] reflect custom keyboard layouts not requiring restaring ime --- .../CustomKeyboardIndexResolver.kt | 41 +++- .../ime_service/IMEService.kt | 198 ++++++++++++++++-- .../ime_service/adapters/SuggestionAdapter.kt | 10 +- .../CustomKeyboardIndexResolverTest.kt | 96 +++++++++ 4 files changed, 325 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolver.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolver.kt index b78a24525..5bd2a7b72 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolver.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolver.kt @@ -7,7 +7,9 @@ enum class CustomKeyboardSelectionReason { InitialDefault, UserTabClick, UserNextTab, - MoveToStableId + MoveToStableId, + LayoutsChangedKeepStableId, + LayoutsChangedFallbackIndex } data class InitialCustomKeyboardSelection( @@ -15,6 +17,12 @@ data class InitialCustomKeyboardSelection( val reason: CustomKeyboardSelectionReason ) +data class ResolvedCustomKeyboardSelection( + val index: Int, + val stableId: String, + val reason: CustomKeyboardSelectionReason +) + fun resolveCustomKeyboardIndexByStableId( layouts: List, stableId: String @@ -57,6 +65,34 @@ fun resolveInitialCustomKeyboardSelection( ) } +fun resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts: List, + selectedStableId: String?, + previousIndex: Int +): ResolvedCustomKeyboardSelection? { + if (layouts.isEmpty()) return null + + if (!selectedStableId.isNullOrBlank()) { + val stableIndex = resolveCustomKeyboardIndexByStableId(layouts, selectedStableId) + if (stableIndex != null) { + return ResolvedCustomKeyboardSelection( + index = stableIndex, + stableId = layouts[stableIndex].stableId, + reason = CustomKeyboardSelectionReason.LayoutsChangedKeepStableId + ) + } + } + + val fallbackIndex = previousIndex + .takeIf { it in layouts.indices } + ?: 0 + return ResolvedCustomKeyboardSelection( + index = fallbackIndex, + stableId = layouts[fallbackIndex].stableId, + reason = CustomKeyboardSelectionReason.LayoutsChangedFallbackIndex + ) +} + fun shouldPersistCustomKeyboardSelection( layout: CustomKeyboardLayout, rememberLast: Boolean, @@ -67,5 +103,6 @@ fun shouldPersistCustomKeyboardSelection( return reason == CustomKeyboardSelectionReason.UserTabClick || reason == CustomKeyboardSelectionReason.UserNextTab || - reason == CustomKeyboardSelectionReason.MoveToStableId + reason == CustomKeyboardSelectionReason.MoveToStableId || + reason == CustomKeyboardSelectionReason.LayoutsChangedFallbackIndex } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index 5265ea00e..5084def56 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -754,6 +754,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var candidateTabOrder: List = emptyList() private var customLayouts: List = emptyList() + private var currentCustomKeyboardStableId: String? = null + private var customKeyboardRenderJob: Job? = null private var currentNightMode: Int = 0 @@ -1108,7 +1110,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, goToNextPageForFloatingCandidate() } ioScope.launch { - customLayouts = keyboardRepository.getLayoutsNotFlowEnsuringStableIds() + val initialCustomLayouts = keyboardRepository.getLayoutsNotFlowEnsuringStableIds() + withContext(Dispatchers.Main) { + customLayouts = initialCustomLayouts + currentCustomKeyboardStableId = customLayouts + .getOrNull(currentCustomKeyboardPosition) + ?.stableId + ?.takeIf { it.isNotBlank() } + } shortCurRepository.initDefaultShortcutsIfNeeded() physicalKeyboardShortcutRepository.ensureDefaultShortcuts() } @@ -4610,17 +4619,24 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun setCurrentCustomLayoutTo(flickView: FlickKeyboardView) { + val layout = selectedCustomKeyboardLayoutOrNull() ?: return scope.launch(Dispatchers.IO) { - if (customLayouts.isEmpty()) return@launch - val position = currentCustomKeyboardPosition.coerceIn(customLayouts.indices) - val id = customLayouts[position].layoutId - val dbLayout = keyboardRepository.getFullLayout(id).first() + val id = layout.layoutId + val expectedStableId = layout.stableId + val dbLayout = runCatching { keyboardRepository.getFullLayout(id).first() } + .getOrElse { + Timber.w(it, "setCurrentCustomLayoutTo: layout disappeared id=$id stableId=$expectedStableId") + return@launch + } val finalLayout = keyboardRepository.convertLayout(dbLayout) isCustomLayoutRomajiMode = finalLayout.isRomaji isCustomLayoutDirectMode = finalLayout.isDirectMode isCustomLayoutShiftPressed = false isCustomLayoutCapLock = false withContext(Dispatchers.Main) { + if (!isCurrentCustomKeyboardSelection(layoutId = id, stableId = expectedStableId)) { + return@withContext + } setKeyboardWithDeleteKeyFlickPreferences(flickView, finalLayout) } } @@ -4632,6 +4648,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ?.let { flickView -> setKeyboardWithDeleteKeyFlickPreferences(flickView, layout) } } + private fun setCustomLayoutOnAvailableSurfaces(layout: KeyboardLayout) { + getNormalKeyboardSurface() + ?.customLayout + ?.let { flickView -> setKeyboardWithDeleteKeyFlickPreferences(flickView, layout) } + getFloatingKeyboardSurface() + ?.customLayout + ?.let { flickView -> setKeyboardWithDeleteKeyFlickPreferences(flickView, layout) } + } + private fun refreshDeleteKeyFlickPreferenceLayouts() { val customLayout = getActiveKeyboardSurface()?.customLayout ?: return when (qwertyMode.value) { @@ -7095,7 +7120,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, KeyboardType.CUSTOM -> { Timber.d("updateKeyboardLayout CUSTOM: $isFlickOnlyMode $sumireInputKeyType") - selectInitialCustomKeyboardTab() + if (!selectInitialCustomKeyboardTab()) { + fallbackFromCustomKeyboardIfNeeded() + return@apply + } if (qwertyMode.value != TenKeyQWERTYMode.Number) { _tenKeyQWERTYMode.update { TenKeyQWERTYMode.Custom } } else { @@ -7155,7 +7183,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, when (customKeyboardMode) { KeyboardInputMode.HIRAGANA -> { mainLayoutBinding?.let { mainView -> - selectInitialCustomKeyboardTab() + if (!selectInitialCustomKeyboardTab()) { + fallbackFromCustomKeyboardIfNeeded() + return@let + } mainView.customLayoutDefault.isVisible = true setCurrentInputModeForSession(InputMode.ModeJapanese) mainView.qwertyView.isVisible = false @@ -7292,17 +7323,55 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var isCustomLayoutShiftPressed = false private var isCustomLayoutCapLock = false - private fun selectInitialCustomKeyboardTab() { + private fun selectedCustomKeyboardLayoutOrNull(): CustomKeyboardLayout? { + currentCustomKeyboardStableId + ?.let { stableId -> resolveCustomKeyboardIndexByStableId(customLayouts, stableId) } + ?.let { index -> + currentCustomKeyboardPosition = index + return customLayouts[index] + } + + return customLayouts.getOrNull(currentCustomKeyboardPosition)?.also { layout -> + currentCustomKeyboardStableId = layout.stableId.takeIf { it.isNotBlank() } + } + } + + private fun isCurrentCustomKeyboardSelection(layoutId: Long, stableId: String): Boolean { + val selected = selectedCustomKeyboardLayoutOrNull() ?: return false + if (stableId.isNotBlank()) { + return selected.stableId == stableId + } + return selected.layoutId == layoutId + } + + private fun currentCustomKeyboardStableIdCandidate(): String? { + currentCustomKeyboardStableId + ?.takeIf { it.isNotBlank() } + ?.let { return it } + customLayouts + .getOrNull(currentCustomKeyboardPosition) + ?.stableId + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return appPreference.last_used_custom_keyboard_stable_id + ?.takeIf { it.isNotBlank() } + } + + private fun selectInitialCustomKeyboardTab(): Boolean { Timber.d("selectInitialCustomKeyboardTab") val initialSelection = resolveInitialCustomKeyboardSelection( layouts = customLayouts, rememberLast = appPreference.remember_last_custom_keyboard_preference == true, savedStableId = appPreference.last_used_custom_keyboard_stable_id - ) ?: return + ) ?: run { + clearCurrentCustomKeyboardSelection() + return false + } selectCustomKeyboardTab( index = initialSelection.index, reason = initialSelection.reason ) + return true } private fun selectCustomKeyboardTab( @@ -7314,6 +7383,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } currentCustomKeyboardPosition = index + currentCustomKeyboardStableId = layout.stableId.takeIf { it.isNotBlank() } if (shouldPersistCustomKeyboardSelection( layout = layout, rememberLast = appPreference.remember_last_custom_keyboard_preference == true, @@ -7326,9 +7396,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun renderCustomKeyboardLayout(layout: CustomKeyboardLayout) { - scope.launch(Dispatchers.IO) { + customKeyboardRenderJob?.cancel() + customKeyboardRenderJob = scope.launch(Dispatchers.IO) { val id = layout.layoutId - val dbLayout = keyboardRepository.getFullLayout(id).first() + val expectedStableId = layout.stableId + val dbLayout = runCatching { keyboardRepository.getFullLayout(id).first() } + .getOrElse { + Timber.w(it, "renderCustomKeyboardLayout: layout disappeared id=$id stableId=$expectedStableId") + return@launch + } Timber.d("renderCustomKeyboardLayout: $id $dbLayout") val finalLayout = keyboardRepository.convertLayout(dbLayout) Timber.d("renderCustomKeyboardLayout: ${dbLayout.isRomaji} ${finalLayout.isRomaji}") @@ -7337,8 +7413,77 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, isCustomLayoutShiftPressed = false isCustomLayoutCapLock = false withContext(Dispatchers.Main) { - setCustomLayoutOnActiveSurface(finalLayout) + if (!isCurrentCustomKeyboardSelection(layoutId = id, stableId = expectedStableId)) { + Timber.d("renderCustomKeyboardLayout: skip stale render id=$id stableId=$expectedStableId") + return@withContext + } + setCustomLayoutOnAvailableSurfaces(finalLayout) + } + } + } + + private fun clearCurrentCustomKeyboardSelection() { + currentCustomKeyboardPosition = 0 + currentCustomKeyboardStableId = null + customKeyboardRenderJob?.cancel() + customKeyboardRenderJob = null + } + + private fun fallbackFromCustomKeyboardIfNeeded() { + val fallbackType = keyboardOrder.firstOrNull { it != KeyboardType.CUSTOM } + ?: KeyboardType.TENKEY + Timber.d("fallbackFromCustomKeyboardIfNeeded: fallbackType=$fallbackType") + suggestionAdapter?.updateState(TenKeyQWERTYMode.Default, emptyList()) + showKeyboard(fallbackType) + } + + private fun onCustomKeyboardLayoutsChanged(newLayouts: List) { + val selectedStableId = currentCustomKeyboardStableIdCandidate() + val previousIndex = currentCustomKeyboardPosition + val selection = resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = newLayouts, + selectedStableId = selectedStableId, + previousIndex = previousIndex + ) + + customLayouts = newLayouts + + if (selection == null) { + clearCurrentCustomKeyboardSelection() + if (appPreference.remember_last_custom_keyboard_preference == true) { + appPreference.last_used_custom_keyboard_stable_id = "" + } + if (qwertyMode.value == TenKeyQWERTYMode.Custom) { + suggestionAdapter?.updateState(TenKeyQWERTYMode.Custom, emptyList()) + fallbackFromCustomKeyboardIfNeeded() } + return + } + + currentCustomKeyboardPosition = selection.index + currentCustomKeyboardStableId = selection.stableId.takeIf { it.isNotBlank() } + + val selectedLayout = customLayouts.getOrNull(selection.index) ?: run { + clearCurrentCustomKeyboardSelection() + fallbackFromCustomKeyboardIfNeeded() + return + } + + if (shouldPersistCustomKeyboardSelection( + layout = selectedLayout, + rememberLast = appPreference.remember_last_custom_keyboard_preference == true, + reason = selection.reason + ) + ) { + appPreference.last_used_custom_keyboard_stable_id = selectedLayout.stableId + } + + if (qwertyMode.value == TenKeyQWERTYMode.Custom) { + suggestionAdapter?.updateState(TenKeyQWERTYMode.Custom, customLayouts) + renderCustomKeyboardLayout(selectedLayout) + syncFloatingKeyboardContentForMode(qwertyMode.value) + renderCurrentKeyboardStateOnActiveSurface() + updateFloatingKeyboardSizeForMode(qwertyMode.value) } } @@ -9723,9 +9868,20 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } TenKeyQWERTYMode.Custom -> { - suggestionAdapter?.updateState( - TenKeyQWERTYMode.Custom, customLayouts - ) + if (customLayouts.isEmpty()) { + clearCurrentCustomKeyboardSelection() + fallbackFromCustomKeyboardIfNeeded() + return@collectLatest + } else { + if (selectedCustomKeyboardLayoutOrNull() == null) { + currentCustomKeyboardPosition = 0 + currentCustomKeyboardStableId = + customLayouts.first().stableId.takeIf { stableId -> stableId.isNotBlank() } + } + suggestionAdapter?.updateState( + TenKeyQWERTYMode.Custom, customLayouts + ) + } } TenKeyQWERTYMode.Sumire -> { @@ -9809,12 +9965,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, launch { keyboardRepository.getLayouts().distinctUntilChanged().collectLatest { layouts -> - customLayouts = if (layouts.any { it.stableId.isBlank() }) { + val normalizedLayouts = if (layouts.any { it.stableId.isBlank() }) { keyboardRepository.ensureStableIds() keyboardRepository.getLayoutsNotFlow() } else { layouts } + onCustomKeyboardLayoutsChanged(normalizedLayouts) } } @@ -12346,7 +12503,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun setFirstKeyboardType() { if (keyboardOrder.isNotEmpty()) { - val firstItem = keyboardOrder.first() + val firstItem = if (keyboardOrder.first() == KeyboardType.CUSTOM && customLayouts.isEmpty()) { + keyboardOrder.firstOrNull { it != KeyboardType.CUSTOM } ?: KeyboardType.TENKEY + } else { + keyboardOrder.first() + } when (firstItem) { KeyboardType.TENKEY -> _tenKeyQWERTYMode.update { TenKeyQWERTYMode.Default } KeyboardType.SUMIRE -> _tenKeyQWERTYMode.update { TenKeyQWERTYMode.Sumire } @@ -14463,6 +14624,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun resetAllFlags() { Timber.d("onUpdate resetAllFlags called") + customKeyboardRenderJob?.cancel() + customKeyboardRenderJob = null clearFunctionKeyConversionSource() _inputString.update { "" } _tenKeyQWERTYMode.update { TenKeyQWERTYMode.Default } @@ -14470,6 +14633,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, stringInTail.set("") suggestionClickNum = 0 currentCustomKeyboardPosition = 0 + currentCustomKeyboardStableId = null filteredCandidateList = emptyList() isHenkan.set(false) henkanPressedWithBunsetsuDetect = false diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt index eba010416..6489f57e8 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt @@ -209,12 +209,20 @@ class SuggestionAdapter : RecyclerView.Adapter() { currentMode = mode customLayouts = layouts if (needsFullRefresh) { - notifyItemChanged(layouts.size) + // Custom layout tabs are backed by a different item list from candidate suggestions. + // Layout add/remove/reorder can change item count and view types at the same time, so + // targeted notifyItemChanged calls are unsafe here. Keep this boundary explicit so the + // tab list can be moved to a stableId-based DiffUtil/ListAdapter model later. + notifyDataSetChanged() } } fun updateCustomTabVisibility(visibility: Boolean) { + if (showCustomTab == visibility) return showCustomTab = visibility + if (currentMode is TenKeyQWERTYMode.Custom) { + notifyDataSetChanged() + } } private val diffCallback = object : DiffUtil.ItemCallback() { diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolverTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolverTest.kt index 0c24239af..f110ba2e3 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolverTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardIndexResolverTest.kt @@ -1,6 +1,7 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.repository.ensureStableIdsForLayouts import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -222,6 +223,101 @@ class CustomKeyboardIndexResolverTest { ) } + @Test + fun resolveSelectionAfterLayoutsChangedKeepsSelectedStableIdAfterReorder() { + val reordered = listOf( + layout("B", "stable-b"), + layout("A", "stable-a"), + layout("C", "stable-c") + ) + + assertEquals( + ResolvedCustomKeyboardSelection( + index = 0, + stableId = "stable-b", + reason = CustomKeyboardSelectionReason.LayoutsChangedKeepStableId + ), + resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = reordered, + selectedStableId = "stable-b", + previousIndex = 1 + ) + ) + } + + @Test + fun resolveSelectionAfterLayoutsChangedFallsBackToPreviousIndexWhenSelectedLayoutWasDeleted() { + val afterDelete = listOf( + layout("A", "stable-a"), + layout("C", "stable-c") + ) + + assertEquals( + ResolvedCustomKeyboardSelection( + index = 1, + stableId = "stable-c", + reason = CustomKeyboardSelectionReason.LayoutsChangedFallbackIndex + ), + resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = afterDelete, + selectedStableId = "stable-b", + previousIndex = 1 + ) + ) + } + + @Test + fun resolveSelectionAfterLayoutsChangedFallsBackToFirstWhenPreviousIndexIsInvalid() { + val afterDelete = listOf(layout("A", "stable-a")) + + assertEquals( + ResolvedCustomKeyboardSelection( + index = 0, + stableId = "stable-a", + reason = CustomKeyboardSelectionReason.LayoutsChangedFallbackIndex + ), + resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = afterDelete, + selectedStableId = "stable-b", + previousIndex = 2 + ) + ) + } + + @Test + fun resolveSelectionAfterLayoutsChangedReturnsNullWhenLayoutsBecomeEmpty() { + assertNull( + resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = emptyList(), + selectedStableId = "stable-b", + previousIndex = 1 + ) + ) + } + + @Test + fun resolveSelectionAfterStableIdEnsureKeepsSelectionWhenBlankIdsAreNormalized() { + val ensured = ensureStableIdsForLayouts( + listOf( + layout("A", ""), + layout("B", "stable-b") + ) + ) { "generated-a" } + + assertEquals( + ResolvedCustomKeyboardSelection( + index = 1, + stableId = "stable-b", + reason = CustomKeyboardSelectionReason.LayoutsChangedKeepStableId + ), + resolveCustomKeyboardSelectionAfterLayoutsChanged( + layouts = ensured, + selectedStableId = "stable-b", + previousIndex = 1 + ) + ) + } + private fun layout(name: String, stableId: String): CustomKeyboardLayout { return CustomKeyboardLayout( name = name,