Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,119 @@ interface KeyboardLayoutDao {
@Query("SELECT * FROM keyboard_layouts WHERE layoutId = :id")
fun getFullLayoutById(id: Long): Flow<FullKeyboardLayout>

/**
* 既存レイアウトの 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<KeyDefinition>,
flicksMap: Map<String, List<FlickMapping>>,
circularFlicksMap: Map<String, List<CircularFlickMapping>>,
twoStepFlicksMap: Map<String, List<TwoStepFlickMapping>>,
longPressFlicksMap: Map<String, List<LongPressFlickMapping>>,
twoStepLongPressFlicksMap: Map<String, List<TwoStepLongPressMappingEntity>>,
spacers: List<SpacerDefinition> = 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<FlickMapping>()
identifierToIdMap.forEach { (identifier, realKeyId) ->
flicksMap[identifier]?.forEach { flick ->
flicksWithRealKeyIds.add(flick.copy(ownerKeyId = realKeyId))
}
}
if (flicksWithRealKeyIds.isNotEmpty()) {
insertFlickMappings(flicksWithRealKeyIds)
}

val circularFlicksWithRealKeyIds = mutableListOf<CircularFlickMapping>()
identifierToIdMap.forEach { (identifier, realKeyId) ->
circularFlicksMap[identifier]?.forEach { flick ->
circularFlicksWithRealKeyIds.add(flick.copy(ownerKeyId = realKeyId))
}
}
if (circularFlicksWithRealKeyIds.isNotEmpty()) {
insertCircularFlickMappings(circularFlicksWithRealKeyIds)
}

val twoStepWithRealKeyIds = mutableListOf<TwoStepFlickMapping>()
identifierToIdMap.forEach { (identifier, realKeyId) ->
twoStepFlicksMap[identifier]?.forEach { mapping ->
twoStepWithRealKeyIds.add(mapping.copy(ownerKeyId = realKeyId))
}
}
if (twoStepWithRealKeyIds.isNotEmpty()) {
insertTwoStepFlickMappings(twoStepWithRealKeyIds)
}

val longPressWithRealKeyIds = mutableListOf<LongPressFlickMapping>()
identifierToIdMap.forEach { (identifier, realKeyId) ->
longPressFlicksMap[identifier]?.forEach { mapping ->
longPressWithRealKeyIds.add(mapping.copy(ownerKeyId = realKeyId))
}
}
if (longPressWithRealKeyIds.isNotEmpty()) {
insertLongPressFlickMappings(longPressWithRealKeyIds)
}

val twoStepLongPressWithRealKeyIds = mutableListOf<TwoStepLongPressMappingEntity>()
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(
Expand All @@ -46,7 +156,7 @@ interface KeyboardLayoutDao {
longPressFlicksMap: Map<String, List<LongPressFlickMapping>>,
twoStepLongPressFlicksMap: Map<String, List<TwoStepLongPressMappingEntity>>,
spacers: List<SpacerDefinition> = emptyList()
) {
): Long {
val layoutId = insertLayout(layout)

if (spacers.isNotEmpty()) {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading