diff --git a/app/build.gradle b/app/build.gradle index 79d65bfdf..b698175b7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 744 - versionName "1.7.50" + versionCode 746 + versionName "1.7.52" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d91915804..096fec2fb 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -23,6 +23,7 @@ -keep class com.kazumaproject.markdownhelperkeyboard.user_dictionary.database.UserWord { *; } -keep class com.kazumaproject.markdownhelperkeyboard.user_template.database.UserTemplate { *; } -keep class com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.** { *; } +-keep class com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.** { *; } -keep class com.kazumaproject.markdownhelperkeyboard.learning.database.LearnEntity { *; } -keep class com.kazumaproject.markdownhelperkeyboard.clipboard_history.database.ClipboardHistoryItem { *; } -keep class com.kazumaproject.markdownhelperkeyboard.custom_romaji.database.RomajiMapEntity { *; } @@ -37,6 +38,8 @@ # Keep Gson generic type metadata and annotations used by backup import/export parsing. -keepattributes Signature +-keepattributes RuntimeVisibleAnnotations +-keepattributes RuntimeInvisibleAnnotations -keepattributes *Annotation* -keep class com.google.gson.reflect.TypeToken { *; } -keep class * extends com.google.gson.reflect.TypeToken diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/FullKeyboardLayout.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/FullKeyboardLayout.kt index 60c146544..6909b36b2 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/FullKeyboardLayout.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/FullKeyboardLayout.kt @@ -14,5 +14,15 @@ data class FullKeyboardLayout( entity = KeyDefinition::class, entityColumn = "ownerLayoutId" ) - val keysWithFlicks: List + val keysWithFlicks: List, + /** + * Layout-level SpacerItems (visual gaps that occupy grid cells but + * don't render content). Stored in `spacer_definitions` and joined by + * Room via the [SpacerDefinition.ownerLayoutId] foreign key. + */ + @Relation( + parentColumn = "layoutId", + entityColumn = "ownerLayoutId" + ) + val spacers: List = emptyList() ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt index c738e173f..c9ce5aab5 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt @@ -33,5 +33,9 @@ data class KeyDefinition( val isSpecialKey: Boolean = false, val drawableResId: Int? = null, val keyIdentifier: String, - val action: String? = null + val action: String? = null, + val rowUnits: Int? = null, + val columnUnits: Int? = null, + val rowSpanUnits: Int? = null, + val columnSpanUnits: Int? = null ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/SpacerDefinition.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/SpacerDefinition.kt new file mode 100644 index 000000000..655a8673f --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/SpacerDefinition.kt @@ -0,0 +1,37 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * SpacerItem (KeyboardLayout の中で空き領域を占める「描画されないキー」) + * を永続化するためのエンティティ。 + * + * KeyDefinition と並列にレイアウトに紐づく。 + * + * 行内 Spacer (Shift と文字キーの間など) も先頭 Spacer も、すべてここに保存される。 + */ +@Entity( + tableName = "spacer_definitions", + foreignKeys = [ForeignKey( + entity = CustomKeyboardLayout::class, + parentColumns = ["layoutId"], + childColumns = ["ownerLayoutId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["ownerLayoutId"])] +) +data class SpacerDefinition( + @PrimaryKey(autoGenerate = true) + val spacerId: Long = 0, + val ownerLayoutId: Long, + /** stable id used in KeyboardLayoutItem.id (helps debugging / round-trip). */ + val itemIdentifier: String, + val rowUnits: Int, + val columnUnits: Int, + val rowSpanUnits: Int, + val columnSpanUnits: Int, + val sortOrder: Int = 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 e17b47bb4..5f4773cd4 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 @@ -12,6 +12,7 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMappin import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition 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 kotlinx.coroutines.flow.Flow @@ -43,10 +44,15 @@ interface KeyboardLayoutDao { circularFlicksMap: Map>, twoStepFlicksMap: Map>, longPressFlicksMap: Map>, - twoStepLongPressFlicksMap: Map> + twoStepLongPressFlicksMap: Map>, + spacers: List = emptyList() ) { val layoutId = insertLayout(layout) + if (spacers.isNotEmpty()) { + insertSpacers(spacers.map { it.copy(spacerId = 0, ownerLayoutId = layoutId) }) + } + val keysWithLayoutId = keys.map { it.copy(ownerLayoutId = layoutId) } val newKeyIds = insertKeys(keysWithLayoutId) @@ -126,6 +132,12 @@ interface KeyboardLayoutDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTwoStepLongPressFlickMappings(mappings: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSpacers(spacers: List) + + @Query("DELETE FROM spacer_definitions WHERE ownerLayoutId = :layoutId") + suspend fun deleteSpacersForLayout(layoutId: Long) + @Query("DELETE FROM keyboard_layouts WHERE layoutId = :layoutId") suspend fun deleteLayout(layoutId: Long) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/ImportableKeyboardLayout.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/ImportableKeyboardLayout.kt new file mode 100644 index 000000000..c2fc33be7 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/ImportableKeyboardLayout.kt @@ -0,0 +1,38 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +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.KeyDefinition +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 用の正規化済み内部モデル。 + * + * 外部 JSON ([KeyboardLayoutExportDto])を読み込み、欠損や null を + * すべて埋めて非 null 化したものをこの型で表す。 + * + * Repository 以降ではこのモデルだけを使うため、null 防御を都度書く必要はない。 + */ +data class ImportableKeyboardLayout( + val layout: CustomKeyboardLayout, + val keysWithFlicks: List, + val spacers: List +) + +/** + * Import 用の正規化済み key + flick 系マッピング。 + * + * 各 List は non-null。 + */ +data class ImportableKeyWithFlicks( + val key: KeyDefinition, + val flicks: List, + val circularFlicks: List, + val twoStepFlicks: List, + val longPressFlicks: List, + val twoStepLongPressFlicks: List +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt new file mode 100644 index 000000000..7e208b574 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt @@ -0,0 +1,763 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.reflect.TypeToken +import com.kazumaproject.custom_keyboard.data.CircularFlickDirection +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection +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.KeyDefinition +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 java.util.UUID +import kotlin.math.ceil + +enum class KeyboardBackupFormat { + LegacyV0Array, + LegacyV0Object, + VersionedV1, + Unsupported +} + +object KeyboardBackupFormatDetector { + fun detect(root: JsonElement): KeyboardBackupFormat { + return when { + root.isJsonArray -> KeyboardBackupFormat.LegacyV0Array + root.isJsonObject -> detectObject(root.asJsonObject) + else -> KeyboardBackupFormat.Unsupported + } + } + + private fun detectObject(obj: JsonObject): KeyboardBackupFormat { + val version = obj["schemaVersion"]?.takeIf { it.isJsonPrimitive }?.asIntOrNull() + return when { + version == KeyboardLayoutJsonImporter.LATEST_SCHEMA_VERSION && + obj["layouts"]?.isJsonArray == true -> KeyboardBackupFormat.VersionedV1 + + version != null -> KeyboardBackupFormat.Unsupported + obj["layouts"]?.isJsonArray == true -> KeyboardBackupFormat.LegacyV0Object + obj.has("layout") || obj.has("keysWithFlicks") -> KeyboardBackupFormat.LegacyV0Object + else -> KeyboardBackupFormat.Unsupported + } + } +} + +object KeyboardBackupParser { + fun parse(root: JsonElement, gson: Gson): KeyboardLayoutJsonParseResult { + return when (KeyboardBackupFormatDetector.detect(root)) { + KeyboardBackupFormat.LegacyV0Array -> parseLegacyArray(root, gson) + KeyboardBackupFormat.LegacyV0Object -> parseLegacyObject(root.asJsonObject, gson) + KeyboardBackupFormat.VersionedV1 -> parseVersionedV1(root.asJsonObject, gson) + KeyboardBackupFormat.Unsupported -> + KeyboardLayoutJsonParseResult.Failure(KeyboardLayoutImportError.UnsupportedFormat) + } + } + + private fun parseLegacyArray(root: JsonElement, gson: Gson): KeyboardLayoutJsonParseResult { + val type = object : TypeToken>() {}.type + val layouts = gson.fromJson?>(root, type) ?: emptyList() + return KeyboardLayoutJsonParseResult.Success(layouts) + } + + private fun parseLegacyObject(obj: JsonObject, gson: Gson): KeyboardLayoutJsonParseResult { + val layoutsElement = obj["layouts"] + if (layoutsElement?.isJsonArray == true) { + val type = object : TypeToken>() {}.type + val layouts = + gson.fromJson?>(layoutsElement, type) ?: emptyList() + return KeyboardLayoutJsonParseResult.Success(layouts) + } + + val singleDto = gson.fromJson(obj, KeyboardLayoutExportDto::class.java) + return KeyboardLayoutJsonParseResult.Success( + if (singleDto != null) listOf(singleDto) else emptyList() + ) + } + + private fun parseVersionedV1(obj: JsonObject, gson: Gson): KeyboardLayoutJsonParseResult { + val fileDto = gson.fromJson(obj, KeyboardLayoutExportFileDto::class.java) + return KeyboardLayoutJsonParseResult.Success(fileDto?.layouts ?: emptyList()) + } +} + +object KeyboardBackupNormalizer { + fun normalize(dtos: List): KeyboardLayoutImportResult { + if (dtos.isEmpty()) { + return KeyboardLayoutImportResult.Failure(KeyboardLayoutImportError.NoImportableLayouts) + } + + val warnings = mutableListOf() + val errors = mutableListOf() + val stableIdsByIndex = dtos.mapIndexed { index, dto -> + val stableId = dto.layout?.stableId?.takeIf { it.isNotBlank() } + stableId ?: if (dto.layout != null) { + UUID.randomUUID().toString().also { + warnings += KeyboardLayoutImportWarning.MissingLayoutIdentifierGenerated(index) + } + } else { + null + } + } + val stableIdsByOldLayoutId = dtos.mapIndexedNotNull { index, dto -> + val oldId = dto.layout?.layoutId ?: return@mapIndexedNotNull null + val stableId = stableIdsByIndex[index] ?: return@mapIndexedNotNull null + oldId.takeIf { it > 0 }?.let { it to stableId } + }.toMap() + + val layouts = dtos.mapIndexedNotNull { layoutIndex, dto -> + normalizeOne( + layoutIndex = layoutIndex, + dto = dto, + generatedStableId = stableIdsByIndex[layoutIndex], + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + warnings = warnings, + errors = errors + ) + } + + return when { + layouts.isNotEmpty() && errors.isEmpty() -> + KeyboardLayoutImportResult.Success(layouts, warnings) + + layouts.isNotEmpty() -> + KeyboardLayoutImportResult.PartialSuccess(layouts, errors, warnings) + + errors.isNotEmpty() -> + KeyboardLayoutImportResult.Failure( + KeyboardLayoutImportError.NoImportableLayouts, + errors + KeyboardLayoutImportError.NoImportableLayouts + ) + + else -> KeyboardLayoutImportResult.Failure(KeyboardLayoutImportError.NoImportableLayouts) + } + } + + private fun normalizeOne( + layoutIndex: Int, + dto: KeyboardLayoutExportDto, + generatedStableId: String?, + stableIdsByOldLayoutId: Map, + warnings: MutableList, + errors: MutableList + ): ImportableKeyboardLayout? { + val layoutDto = dto.layout ?: run { + errors += KeyboardLayoutImportError.MissingLayout(layoutIndex) + return null + } + if (dto.spacers == null) { + warnings += KeyboardLayoutImportWarning.MissingSpacerListTreatedAsEmpty(layoutIndex) + } + if (dto.keysWithFlicks == null) { + errors += KeyboardLayoutImportError.MissingKeys(layoutIndex) + } + + val rawKeys = dto.keysWithFlicks ?: emptyList() + val rawSpacers = dto.spacers ?: emptyList() + val derivedRowCount = deriveRowCount(rawKeys, rawSpacers) + val derivedColumnCount = deriveColumnCount(rawKeys, rawSpacers) + val rowCount = normalizeLayoutDimension( + original = layoutDto.rowCount, + derived = derivedRowCount, + layoutIndex = layoutIndex, + dimensionName = "rowCount", + warnings = warnings, + errors = errors + ) ?: return null + val columnCount = normalizeLayoutDimension( + original = layoutDto.columnCount, + derived = derivedColumnCount, + layoutIndex = layoutIndex, + dimensionName = "columnCount", + warnings = warnings, + errors = errors + ) ?: return null + + val name = layoutDto.name?.takeIf { it.isNotBlank() } ?: run { + errors += KeyboardLayoutImportError.InvalidLayoutSize(layoutIndex, "name is blank") + return null + } + + val normalizedLayout = CustomKeyboardLayout( + layoutId = 0, + name = name, + columnCount = columnCount, + rowCount = rowCount, + isRomaji = layoutDto.isRomaji ?: false, + isDirectMode = layoutDto.isDirectMode ?: false, + createdAt = layoutDto.createdAt?.takeIf { it > 0 } ?: System.currentTimeMillis(), + sortOrder = 0, + stableId = generatedStableId ?: UUID.randomUUID().toString() + ) + + val normalizedKeys = normalizeKeys( + layoutIndex = layoutIndex, + layoutDto = layoutDto, + keyDtos = rawKeys, + rowCount = rowCount, + columnCount = columnCount, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + warnings = warnings, + errors = errors + ) + val normalizedSpacers = normalizeSpacers( + layoutIndex = layoutIndex, + layoutDto = layoutDto, + spacerDtos = rawSpacers, + rowCount = rowCount, + columnCount = columnCount, + errors = errors + ) + + return ImportableKeyboardLayout( + layout = normalizedLayout, + keysWithFlicks = normalizedKeys, + spacers = normalizedSpacers + ) + } + + private fun normalizeKeys( + layoutIndex: Int, + layoutDto: KeyboardLayoutDto, + keyDtos: List, + rowCount: Int, + columnCount: Int, + stableIdsByOldLayoutId: Map, + warnings: MutableList, + errors: MutableList + ): List { + val usedKeyIdentifiers = mutableSetOf() + return keyDtos.mapIndexedNotNull { keyIndex, keyWithFlicksDto -> + val keyDto = keyWithFlicksDto.key ?: run { + errors += KeyboardLayoutImportError.MissingKeys(layoutIndex, keyIndex) + return@mapIndexedNotNull null + } + val validationError = KeyboardBackupValidator.validateKey( + layoutIndex = layoutIndex, + keyIndex = keyIndex, + layoutDto = layoutDto, + keyDto = keyDto, + rowCount = rowCount, + columnCount = columnCount + ) + if (validationError != null) { + errors += validationError + return@mapIndexedNotNull null + } + + val identifier = keyDto.keyIdentifier + ?.takeIf { it.isNotBlank() && it !in usedKeyIdentifiers } + ?: UUID.randomUUID().toString().also { + warnings += KeyboardLayoutImportWarning.MissingKeyIdentifierGenerated( + layoutIndex, + keyIndex + ) + } + usedKeyIdentifiers += identifier + + if (keyWithFlicksDto.flicks == null) { + warnings += KeyboardLayoutImportWarning.MissingFlickListTreatedAsEmpty( + layoutIndex, + keyIndex + ) + } + + val normalizedKey = KeyDefinition( + keyId = 0, + ownerLayoutId = 0, + label = keyDto.label.orEmpty(), + row = keyDto.row ?: 0, + column = keyDto.column ?: 0, + rowSpan = keyDto.rowSpan ?: 1, + colSpan = keyDto.colSpan ?: 1, + keyType = enumValueOrNull(keyDto.keyType) ?: KeyType.NORMAL, + isSpecialKey = keyDto.isSpecialKey ?: false, + drawableResId = keyDto.drawableResId, + keyIdentifier = identifier, + action = remapKeyAction( + action = keyDto.action, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + layoutIndex = layoutIndex, + keyIndex = keyIndex, + warnings = warnings + ), + rowUnits = keyDto.rowUnits?.coerceAtLeast(0), + columnUnits = keyDto.columnUnits?.coerceAtLeast(0), + rowSpanUnits = keyDto.rowSpanUnits?.coerceAtLeast(1), + columnSpanUnits = keyDto.columnSpanUnits?.coerceAtLeast(1) + ) + + ImportableKeyWithFlicks( + key = normalizedKey, + flicks = keyWithFlicksDto.flicks.orEmpty().mapIndexedNotNull { flickIndex, flick -> + normalizeFlick(layoutIndex, keyIndex, flickIndex, keyDto, flick, stableIdsByOldLayoutId, warnings, errors) + }, + circularFlicks = keyWithFlicksDto.circularFlicks.orEmpty() + .mapIndexedNotNull { flickIndex, flick -> + normalizeCircularFlick(layoutIndex, keyIndex, flickIndex, keyDto, flick, stableIdsByOldLayoutId, warnings, errors) + }, + twoStepFlicks = keyWithFlicksDto.twoStepFlicks.orEmpty() + .mapIndexedNotNull { mappingIndex, mapping -> + normalizeTwoStep(layoutIndex, keyIndex, mappingIndex, keyDto, mapping, errors) + }, + longPressFlicks = keyWithFlicksDto.longPressFlicks.orEmpty() + .mapIndexedNotNull { mappingIndex, mapping -> + normalizeLongPress(layoutIndex, keyIndex, mappingIndex, keyDto, mapping, errors) + }, + twoStepLongPressFlicks = keyWithFlicksDto.twoStepLongPressFlicks.orEmpty() + .mapIndexedNotNull { mappingIndex, mapping -> + normalizeTwoStepLongPress(layoutIndex, keyIndex, mappingIndex, keyDto, mapping, errors) + } + ) + } + } + + private fun normalizeSpacers( + layoutIndex: Int, + layoutDto: KeyboardLayoutDto, + spacerDtos: List, + rowCount: Int, + columnCount: Int, + errors: MutableList + ): List { + return spacerDtos.mapIndexedNotNull { spacerIndex, spacer -> + val ownerLayoutId = spacer.ownerLayoutId + val layoutId = layoutDto.layoutId + if (ownerLayoutId != null && ownerLayoutId > 0 && layoutId != null && layoutId > 0 && + ownerLayoutId != layoutId + ) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex = layoutIndex, + mappingIndex = spacerIndex, + reason = "spacer.ownerLayoutId does not match layout.layoutId" + ) + return@mapIndexedNotNull null + } + val rowUnits = spacer.rowUnits ?: 0 + val columnUnits = spacer.columnUnits ?: 0 + val rowSpanUnits = spacer.rowSpanUnits ?: 1 + val columnSpanUnits = spacer.columnSpanUnits ?: 1 + if (rowUnits < 0 || columnUnits < 0 || rowSpanUnits <= 0 || columnSpanUnits <= 0) { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex = layoutIndex, + keyIndex = spacerIndex, + reason = "spacer placement has negative position or non-positive span" + ) + return@mapIndexedNotNull null + } + val rowEnd = ceil((rowUnits + rowSpanUnits) / 2.0).toInt() + val columnEnd = ceil((columnUnits + columnSpanUnits) / 2.0).toInt() + if (rowEnd > rowCount || columnEnd > columnCount) { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex = layoutIndex, + keyIndex = spacerIndex, + reason = "spacer placement exceeds layout size" + ) + return@mapIndexedNotNull null + } + SpacerDefinition( + spacerId = 0, + ownerLayoutId = 0, + itemIdentifier = spacer.itemIdentifier?.takeIf { it.isNotBlank() } + ?: UUID.randomUUID().toString(), + rowUnits = rowUnits, + columnUnits = columnUnits, + rowSpanUnits = rowSpanUnits, + columnSpanUnits = columnSpanUnits, + sortOrder = spacer.sortOrder ?: 0 + ) + } + } + + private fun normalizeFlick( + layoutIndex: Int, + keyIndex: Int, + flickIndex: Int, + keyDto: KeyDefinitionDto, + flick: FlickMappingDto, + stableIdsByOldLayoutId: Map, + warnings: MutableList, + errors: MutableList + ): FlickMapping? { + if (!ownerKeyMatches(keyDto, flick.ownerKeyId)) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex, + keyIndex, + flickIndex, + "flick.ownerKeyId does not match key.keyId" + ) + return null + } + val direction = enumValueOrNull(flick.flickDirection) ?: run { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "unknown flickDirection at flick index $flickIndex" + ) + return null + } + val remapped = remapLayoutSwitchFlickAction( + actionType = flick.actionType ?: "INPUT_TEXT", + actionValue = flick.actionValue, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + layoutIndex = layoutIndex, + itemKind = "flick", + itemIndex = flickIndex, + warnings = warnings + ) + return FlickMapping( + ownerKeyId = 0, + stateIndex = flick.stateIndex ?: 0, + flickDirection = direction, + actionType = remapped.first, + actionValue = remapped.second + ) + } + + private fun normalizeCircularFlick( + layoutIndex: Int, + keyIndex: Int, + flickIndex: Int, + keyDto: KeyDefinitionDto, + flick: CircularFlickMappingDto, + stableIdsByOldLayoutId: Map, + warnings: MutableList, + errors: MutableList + ): CircularFlickMapping? { + if (!ownerKeyMatches(keyDto, flick.ownerKeyId)) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex, + keyIndex, + flickIndex, + "circularFlick.ownerKeyId does not match key.keyId" + ) + return null + } + val direction = enumValueOrNull(flick.circularDirection) ?: run { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "unknown circularDirection at circular flick index $flickIndex" + ) + return null + } + val remapped = remapLayoutSwitchFlickAction( + actionType = flick.actionType ?: "INPUT_TEXT", + actionValue = flick.actionValue, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + layoutIndex = layoutIndex, + itemKind = "circularFlick", + itemIndex = flickIndex, + warnings = warnings + ) + return CircularFlickMapping( + ownerKeyId = 0, + stateIndex = flick.stateIndex ?: 0, + circularDirection = direction, + actionType = remapped.first, + actionValue = remapped.second + ) + } + + private fun normalizeTwoStep( + layoutIndex: Int, + keyIndex: Int, + mappingIndex: Int, + keyDto: KeyDefinitionDto, + mapping: TwoStepFlickMappingDto, + errors: MutableList + ): TwoStepFlickMapping? { + if (!ownerKeyMatches(keyDto, mapping.ownerKeyId)) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex, + keyIndex, + mappingIndex, + "twoStepFlick.ownerKeyId does not match key.keyId" + ) + return null + } + val first = enumValueOrNull(mapping.firstDirection) + val second = enumValueOrNull(mapping.secondDirection) + if (first == null || second == null) { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "unknown two-step direction at mapping index $mappingIndex" + ) + return null + } + return TwoStepFlickMapping(0, first, second, mapping.output.orEmpty()) + } + + private fun normalizeLongPress( + layoutIndex: Int, + keyIndex: Int, + mappingIndex: Int, + keyDto: KeyDefinitionDto, + mapping: LongPressFlickMappingDto, + errors: MutableList + ): LongPressFlickMapping? { + if (!ownerKeyMatches(keyDto, mapping.ownerKeyId)) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex, + keyIndex, + mappingIndex, + "longPressFlick.ownerKeyId does not match key.keyId" + ) + return null + } + val direction = enumValueOrNull(mapping.flickDirection) ?: run { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "unknown long-press direction at mapping index $mappingIndex" + ) + return null + } + return LongPressFlickMapping(0, direction, mapping.output.orEmpty()) + } + + private fun normalizeTwoStepLongPress( + layoutIndex: Int, + keyIndex: Int, + mappingIndex: Int, + keyDto: KeyDefinitionDto, + mapping: TwoStepLongPressMappingDto, + errors: MutableList + ): TwoStepLongPressMappingEntity? { + if (!ownerKeyMatches(keyDto, mapping.ownerKeyId)) { + errors += KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex, + keyIndex, + mappingIndex, + "twoStepLongPressFlick.ownerKeyId does not match key.keyId" + ) + return null + } + val first = enumValueOrNull(mapping.firstDirection) + val second = enumValueOrNull(mapping.secondDirection) + if (first == null || second == null) { + errors += KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "unknown two-step long-press direction at mapping index $mappingIndex" + ) + return null + } + return TwoStepLongPressMappingEntity(0, first, second, mapping.output.orEmpty()) + } + + private fun normalizeLayoutDimension( + original: Int?, + derived: Int, + layoutIndex: Int, + dimensionName: String, + warnings: MutableList, + errors: MutableList + ): Int? { + if (original != null && original > 0) return original + if (derived > 0) { + warnings += KeyboardLayoutImportWarning.InvalidRowColumnCorrected( + layoutIndex, + dimensionName, + 0 + ) + return derived + } + errors += KeyboardLayoutImportError.InvalidLayoutSize( + layoutIndex, + "$dimensionName is missing and cannot be derived" + ) + return null + } + + private fun deriveRowCount( + keys: List, + spacers: List + ): Int { + val keyRows = keys.maxOfOrNull { keyWithFlicks -> + val key = keyWithFlicks.key ?: return@maxOfOrNull 0 + val row = key.row ?: 0 + val rowSpan = key.rowSpan ?: 1 + if (row < 0 || rowSpan <= 0) 0 else row + rowSpan + } ?: 0 + val spacerRows = spacers.maxOfOrNull { + val rowUnits = it.rowUnits ?: 0 + val rowSpanUnits = it.rowSpanUnits ?: 1 + if (rowUnits < 0 || rowSpanUnits <= 0) 0 else ceil((rowUnits + rowSpanUnits) / 2.0).toInt() + } ?: 0 + return maxOf(keyRows, spacerRows) + } + + private fun deriveColumnCount( + keys: List, + spacers: List + ): Int { + val keyColumns = keys.maxOfOrNull { keyWithFlicks -> + val key = keyWithFlicks.key ?: return@maxOfOrNull 0 + val column = key.column ?: 0 + val colSpan = key.colSpan ?: 1 + if (column < 0 || colSpan <= 0) 0 else column + colSpan + } ?: 0 + val spacerColumns = spacers.maxOfOrNull { + val columnUnits = it.columnUnits ?: 0 + val columnSpanUnits = it.columnSpanUnits ?: 1 + if (columnUnits < 0 || columnSpanUnits <= 0) 0 else ceil((columnUnits + columnSpanUnits) / 2.0).toInt() + } ?: 0 + return maxOf(keyColumns, spacerColumns) + } + + private fun remapKeyAction( + action: String?, + stableIdsByOldLayoutId: Map, + layoutIndex: Int, + keyIndex: Int, + warnings: MutableList + ): String? { + if (action.isNullOrBlank()) return action + val movePrefix = "MoveToCustomKeyboard:" + if (action.startsWith(movePrefix)) { + val rawTarget = action.removePrefix(movePrefix) + val oldId = rawTarget.toLongOrNull() + if (oldId != null) { + val stableId = stableIdsByOldLayoutId[oldId] + if (stableId != null) return "$movePrefix$stableId" + warnings += KeyboardLayoutImportWarning.LayoutSwitchReferenceCouldNotBeResolved( + layoutIndex, + "key", + keyIndex + ) + return null + } + } + if (action.toLongOrNull() != null) { + warnings += KeyboardLayoutImportWarning.LayoutSwitchReferenceCouldNotBeResolved( + layoutIndex, + "key", + keyIndex + ) + return null + } + return action + } + + private fun remapLayoutSwitchFlickAction( + actionType: String, + actionValue: String?, + stableIdsByOldLayoutId: Map, + layoutIndex: Int, + itemKind: String, + itemIndex: Int, + warnings: MutableList + ): Pair { + if (actionType != "MoveToCustomKeyboard") return actionType to actionValue + val oldId = actionValue?.toLongOrNull() + if (oldId == null) return actionType to actionValue + val stableId = stableIdsByOldLayoutId[oldId] + if (stableId != null) return actionType to stableId + warnings += KeyboardLayoutImportWarning.LayoutSwitchReferenceCouldNotBeResolved( + layoutIndex, + itemKind, + itemIndex + ) + return "INPUT_TEXT" to "" + } + + private fun ownerKeyMatches(keyDto: KeyDefinitionDto, ownerKeyId: Long?): Boolean { + val keyId = keyDto.keyId + return keyId == null || keyId <= 0 || ownerKeyId == null || ownerKeyId <= 0 || ownerKeyId == keyId + } +} + +object KeyboardBackupValidator { + fun isLayoutBackupRoot(root: JsonElement): Boolean { + return when (KeyboardBackupFormatDetector.detect(root)) { + KeyboardBackupFormat.LegacyV0Array -> + root.asJsonArray.any { isLayoutDtoObject(it) } + + KeyboardBackupFormat.LegacyV0Object, + KeyboardBackupFormat.VersionedV1 -> { + val obj = root.asJsonObject + obj["layouts"]?.takeIf { it.isJsonArray }?.asJsonArray?.any { + isLayoutDtoObject(it) + } == true || isLayoutDtoObject(obj) + } + + KeyboardBackupFormat.Unsupported -> false + } + } + + fun validateKey( + layoutIndex: Int, + keyIndex: Int, + layoutDto: KeyboardLayoutDto, + keyDto: KeyDefinitionDto, + rowCount: Int, + columnCount: Int + ): KeyboardLayoutImportError? { + val ownerLayoutId = keyDto.ownerLayoutId + val layoutId = layoutDto.layoutId + if (ownerLayoutId != null && ownerLayoutId > 0 && layoutId != null && layoutId > 0 && + ownerLayoutId != layoutId + ) { + return KeyboardLayoutImportError.BrokenOwnerReference( + layoutIndex = layoutIndex, + keyIndex = keyIndex, + reason = "key.ownerLayoutId does not match layout.layoutId" + ) + } + + val row = keyDto.row ?: 0 + val column = keyDto.column ?: 0 + val rowSpan = keyDto.rowSpan ?: 1 + val colSpan = keyDto.colSpan ?: 1 + return when { + row < 0 || column < 0 -> + KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "row and column must be >= 0" + ) + + rowSpan <= 0 || colSpan <= 0 -> + KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "rowSpan and colSpan must be > 0" + ) + + row + rowSpan > rowCount || column + colSpan > columnCount -> + KeyboardLayoutImportError.InvalidKeyPlacement( + layoutIndex, + keyIndex, + "key placement exceeds layout size" + ) + + else -> null + } + } + + private fun isLayoutDtoObject(element: JsonElement): Boolean { + if (!element.isJsonObject) return false + val obj: JsonObject = element.asJsonObject + return obj.has("layout") && (obj.has("keysWithFlicks") || obj.has("spacers")) + } +} + +object KeyboardLayoutImportNormalizer { + fun normalize(dtos: List): KeyboardLayoutImportResult = + KeyboardBackupNormalizer.normalize(dtos) +} + +private inline fun > enumValueOrNull(value: String?): T? { + if (value.isNullOrBlank()) return null + return enumValues().firstOrNull { it.name == value } +} + +private fun JsonElement.asIntOrNull(): Int? = runCatching { asInt }.getOrNull() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutBackupImporter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutBackupImporter.kt new file mode 100644 index 000000000..7bc474cf8 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutBackupImporter.kt @@ -0,0 +1,229 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import android.util.Log +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.StringReader +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilderFactory + +private const val TAG = "KeyboardBackupImport" + +enum class KeyboardLayoutBackupFormat { + JSON_ROOT_ARRAY, + JSON_ROOT_OBJECT, + SHARED_PREFERENCES_XML, + UNSUPPORTED_TEXT +} + +object KeyboardLayoutBackupKeys { + /* + * The custom keyboard exporter has historically used + * "keyboard_layouts_backup.json" as the document title. SharedPreferences + * XML exports store the same logical payload under the extension-less + * string key shown in legacy backup files. + */ + const val KEYBOARD_LAYOUTS_BACKUP = "keyboard_layouts_backup" + val KNOWN_XML_PAYLOAD_KEYS: Set = setOf(KEYBOARD_LAYOUTS_BACKUP) +} + +object KeyboardLayoutBackupImporter { + fun importText(rawText: String): KeyboardLayoutImportResult { + val sanitized = KeyboardLayoutBackupFormatDetector.sanitize(rawText) + val format = KeyboardLayoutBackupFormatDetector.detect(sanitized) + + if (format == KeyboardLayoutBackupFormat.UNSUPPORTED_TEXT && sanitized.isBlank()) { + val error = KeyboardLayoutImportError.EmptyInput + logFailure(format, error, sanitized.length, null) + return KeyboardLayoutImportResult.Failure(error) + } + + val payloadResult = when (format) { + KeyboardLayoutBackupFormat.JSON_ROOT_ARRAY, + KeyboardLayoutBackupFormat.JSON_ROOT_OBJECT -> PayloadExtractionResult.Success( + payload = sanitized, + format = format, + selectedXmlKeyName = null + ) + + KeyboardLayoutBackupFormat.SHARED_PREFERENCES_XML -> + KeyboardLayoutBackupPayloadExtractor.extractFromXml(sanitized) + + KeyboardLayoutBackupFormat.UNSUPPORTED_TEXT -> { + val error = KeyboardLayoutImportError.UnsupportedFormat + logFailure(format, error, sanitized.length, null) + return KeyboardLayoutImportResult.Failure(error) + } + } + + val payload = when (payloadResult) { + is PayloadExtractionResult.Success -> payloadResult.payload + is PayloadExtractionResult.Failure -> { + logFailure(format, payloadResult.error, sanitized.length, null) + return KeyboardLayoutImportResult.Failure(payloadResult.error) + } + } + + val parseResult = KeyboardLayoutJsonImporter.parse(payload) + logResult( + format = format, + payloadLength = payload.length, + selectedXmlKeyName = (payloadResult as? PayloadExtractionResult.Success)?.selectedXmlKeyName, + result = parseResult + ) + return parseResult + } + + private fun logResult( + format: KeyboardLayoutBackupFormat, + payloadLength: Int, + selectedXmlKeyName: String?, + result: KeyboardLayoutImportResult + ) { + when (result) { + is KeyboardLayoutImportResult.Success -> safeLog( + priority = Log.WARN, + message = "import success format=$format layouts=${result.layouts.size} warnings=${result.warnings.size} payloadLength=$payloadLength selectedXmlKey=$selectedXmlKeyName" + ) + + is KeyboardLayoutImportResult.PartialSuccess -> safeLog( + priority = Log.WARN, + message = "import partial format=$format layouts=${result.layouts.size} errors=${result.errors.size} warnings=${result.warnings.size} payloadLength=$payloadLength selectedXmlKey=$selectedXmlKeyName" + ) + + is KeyboardLayoutImportResult.Failure -> + logFailure(format, result.error, payloadLength, selectedXmlKeyName) + } + } + + private fun logFailure( + format: KeyboardLayoutBackupFormat, + error: KeyboardLayoutImportError, + payloadLength: Int, + selectedXmlKeyName: String? + ) { + val message = + "import failure format=$format error=${error::class.simpleName} payloadLength=$payloadLength selectedXmlKey=$selectedXmlKeyName" + safeLog(priority = Log.ERROR, message = message) + } + + private fun safeLog(priority: Int, message: String) { + try { + if (priority >= Log.ERROR) { + Log.e(TAG, message) + } else { + Log.w(TAG, message) + } + } catch (_: Throwable) { + // JVM unit tests run without Android's Log implementation. + } + } +} + +object KeyboardLayoutBackupFormatDetector { + private val BOM_CHAR: Char = 0xFEFF.toChar() + private val NULL_CHAR: String = 0.toChar().toString() + + fun sanitize(rawText: String): String { + return rawText + .trimStart(BOM_CHAR) + .replace(NULL_CHAR, "") + .replace("\r\n", "\n") + .replace('\r', '\n') + .trim() + } + + fun detect(sanitizedText: String): KeyboardLayoutBackupFormat { + if (sanitizedText.isBlank()) return KeyboardLayoutBackupFormat.UNSUPPORTED_TEXT + return when { + sanitizedText.startsWith("[") -> KeyboardLayoutBackupFormat.JSON_ROOT_ARRAY + sanitizedText.startsWith("{") -> KeyboardLayoutBackupFormat.JSON_ROOT_OBJECT + sanitizedText.startsWith(" + KeyboardLayoutBackupFormat.SHARED_PREFERENCES_XML + + else -> KeyboardLayoutBackupFormat.UNSUPPORTED_TEXT + } + } +} + +sealed class PayloadExtractionResult { + data class Success( + val payload: String, + val format: KeyboardLayoutBackupFormat, + val selectedXmlKeyName: String? + ) : PayloadExtractionResult() + + data class Failure(val error: KeyboardLayoutImportError) : PayloadExtractionResult() +} + +object KeyboardLayoutBackupPayloadExtractor { + fun extractFromXml(xmlText: String): PayloadExtractionResult { + val strings = try { + parseSharedPreferencesStrings(xmlText) + } catch (e: Exception) { + return PayloadExtractionResult.Failure( + KeyboardLayoutImportError.InvalidXml( + exceptionClass = e::class.java.simpleName, + message = e.message + ) + ) + } + + KeyboardLayoutBackupKeys.KNOWN_XML_PAYLOAD_KEYS.forEach { knownKey -> + strings.firstOrNull { it.name == knownKey }?.let { entry -> + return PayloadExtractionResult.Success( + payload = KeyboardLayoutBackupFormatDetector.sanitize(entry.value), + format = KeyboardLayoutBackupFormat.SHARED_PREFERENCES_XML, + selectedXmlKeyName = entry.name + ) + } + } + + strings.firstOrNull { entry -> + KeyboardLayoutJsonImporter.looksLikeLayoutBackup(entry.value) + }?.let { entry -> + return PayloadExtractionResult.Success( + payload = KeyboardLayoutBackupFormatDetector.sanitize(entry.value), + format = KeyboardLayoutBackupFormat.SHARED_PREFERENCES_XML, + selectedXmlKeyName = entry.name + ) + } + + return PayloadExtractionResult.Failure(KeyboardLayoutImportError.NoLayoutPayloadFound) + } + + private fun parseSharedPreferencesStrings(xmlText: String): List { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = false + isExpandEntityReferences = false + setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) + setFeatureIfSupported("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeatureIfSupported("http://xml.org/sax/features/external-general-entities", false) + setFeatureIfSupported("http://xml.org/sax/features/external-parameter-entities", false) + setFeatureIfSupported("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + } + val document = factory.newDocumentBuilder() + .parse(InputSource(StringReader(xmlText))) + + val nodes = document.getElementsByTagName("string") + return (0 until nodes.length).mapNotNull { index -> + val element = nodes.item(index) as? Element ?: return@mapNotNull null + val name = element.getAttribute("name").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + SharedPreferencesStringEntry(name = name, value = element.textContent.orEmpty()) + } + } + + private fun DocumentBuilderFactory.setFeatureIfSupported(feature: String, enabled: Boolean) { + try { + setFeature(feature, enabled) + } catch (_: Exception) { + // Some Android XML parsers do not expose every feature. + } + } +} + +private data class SharedPreferencesStringEntry( + val name: String, + val value: String +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt new file mode 100644 index 000000000..61d2ddd67 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt @@ -0,0 +1,173 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import com.google.gson.annotations.SerializedName + +/** + * External JSON schema for custom keyboard backup files. + * + * These DTOs are intentionally separate from Room entities and runtime models. + * Every external JSON key is pinned with @SerializedName so R8 field renaming + * cannot change the backup contract. + */ +data class KeyboardLayoutExportFileDto( + @SerializedName("schemaVersion") + val schemaVersion: Int? = null, + @SerializedName("layouts") + val layouts: List? = null +) + +data class KeyboardLayoutExportDto( + @SerializedName("layout") + val layout: KeyboardLayoutDto? = null, + @SerializedName("keysWithFlicks") + val keysWithFlicks: List? = null, + @SerializedName("spacers") + val spacers: List? = null +) + +data class KeyboardLayoutDto( + @SerializedName("layoutId") + val layoutId: Long? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("columnCount") + val columnCount: Int? = null, + @SerializedName("rowCount") + val rowCount: Int? = null, + @SerializedName("isRomaji") + val isRomaji: Boolean? = null, + @SerializedName("isDirectMode") + val isDirectMode: Boolean? = null, + @SerializedName("createdAt") + val createdAt: Long? = null, + @SerializedName("sortOrder") + val sortOrder: Int? = null, + @SerializedName("stableId") + val stableId: String? = null +) + +data class KeyWithFlicksExportDto( + @SerializedName("key") + val key: KeyDefinitionDto? = null, + @SerializedName("flicks") + val flicks: List? = null, + @SerializedName("circularFlicks") + val circularFlicks: List? = null, + @SerializedName("twoStepFlicks") + val twoStepFlicks: List? = null, + @SerializedName("longPressFlicks") + val longPressFlicks: List? = null, + @SerializedName("twoStepLongPressFlicks") + val twoStepLongPressFlicks: List? = null +) + +data class KeyDefinitionDto( + @SerializedName("keyId") + val keyId: Long? = null, + @SerializedName("ownerLayoutId") + val ownerLayoutId: Long? = null, + @SerializedName("keyIdentifier") + val keyIdentifier: String? = null, + @SerializedName("label") + val label: String? = null, + @SerializedName("row") + val row: Int? = null, + @SerializedName("column") + val column: Int? = null, + @SerializedName("rowSpan") + val rowSpan: Int? = null, + @SerializedName("colSpan") + val colSpan: Int? = null, + @SerializedName("keyType") + val keyType: String? = null, + @SerializedName("isSpecialKey") + val isSpecialKey: Boolean? = null, + @SerializedName("drawableResId") + val drawableResId: Int? = null, + @SerializedName("action") + val action: String? = null, + @SerializedName("rowUnits") + val rowUnits: Int? = null, + @SerializedName("columnUnits") + val columnUnits: Int? = null, + @SerializedName("rowSpanUnits") + val rowSpanUnits: Int? = null, + @SerializedName("columnSpanUnits") + val columnSpanUnits: Int? = null +) + +data class FlickMappingDto( + @SerializedName("ownerKeyId") + val ownerKeyId: Long? = null, + @SerializedName("stateIndex") + val stateIndex: Int? = null, + @SerializedName("flickDirection") + val flickDirection: String? = null, + @SerializedName("actionType") + val actionType: String? = null, + @SerializedName("actionValue") + val actionValue: String? = null +) + +data class CircularFlickMappingDto( + @SerializedName("ownerKeyId") + val ownerKeyId: Long? = null, + @SerializedName("stateIndex") + val stateIndex: Int? = null, + @SerializedName("circularDirection") + val circularDirection: String? = null, + @SerializedName("actionType") + val actionType: String? = null, + @SerializedName("actionValue") + val actionValue: String? = null +) + +data class TwoStepFlickMappingDto( + @SerializedName("ownerKeyId") + val ownerKeyId: Long? = null, + @SerializedName("firstDirection") + val firstDirection: String? = null, + @SerializedName("secondDirection") + val secondDirection: String? = null, + @SerializedName("output") + val output: String? = null +) + +data class LongPressFlickMappingDto( + @SerializedName("ownerKeyId") + val ownerKeyId: Long? = null, + @SerializedName("flickDirection") + val flickDirection: String? = null, + @SerializedName("output") + val output: String? = null +) + +data class TwoStepLongPressMappingDto( + @SerializedName("ownerKeyId") + val ownerKeyId: Long? = null, + @SerializedName("firstDirection") + val firstDirection: String? = null, + @SerializedName("secondDirection") + val secondDirection: String? = null, + @SerializedName("output") + val output: String? = null +) + +data class SpacerDefinitionDto( + @SerializedName("spacerId") + val spacerId: Long? = null, + @SerializedName("ownerLayoutId") + val ownerLayoutId: Long? = null, + @SerializedName("itemIdentifier") + val itemIdentifier: String? = null, + @SerializedName("rowUnits") + val rowUnits: Int? = null, + @SerializedName("columnUnits") + val columnUnits: Int? = null, + @SerializedName("rowSpanUnits") + val rowSpanUnits: Int? = null, + @SerializedName("columnSpanUnits") + val columnSpanUnits: Int? = null, + @SerializedName("sortOrder") + val sortOrder: Int? = null +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutImportResult.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutImportResult.kt new file mode 100644 index 000000000..823a7227c --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutImportResult.kt @@ -0,0 +1,109 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +sealed class KeyboardLayoutImportResult { + data class Success( + val layouts: List, + val warnings: List = emptyList() + ) : KeyboardLayoutImportResult() + + data class PartialSuccess( + val layouts: List, + val errors: List, + val warnings: List = emptyList() + ) : KeyboardLayoutImportResult() + + data class Failure( + val error: KeyboardLayoutImportError, + val errors: List = listOf(error) + ) : KeyboardLayoutImportResult() +} + +sealed class KeyboardLayoutImportError { + data object EmptyInput : KeyboardLayoutImportError() + data object UnsupportedFormat : KeyboardLayoutImportError() + data class MalformedJson( + val exceptionClass: String? = null, + val message: String? = null + ) : KeyboardLayoutImportError() + + data class InvalidJson( + val exceptionClass: String? = null, + val message: String? = null + ) : KeyboardLayoutImportError() + + data class InvalidXml( + val exceptionClass: String? = null, + val message: String? = null + ) : KeyboardLayoutImportError() + + data object NoLayoutPayloadFound : KeyboardLayoutImportError() + data object SchemaMismatch : KeyboardLayoutImportError() + data object NoImportableLayouts : KeyboardLayoutImportError() + data class MissingLayout(val layoutIndex: Int) : KeyboardLayoutImportError() + data class MissingKeys(val layoutIndex: Int, val keyIndex: Int? = null) : + KeyboardLayoutImportError() + + data class InvalidLayoutSize(val layoutIndex: Int, val reason: String) : + KeyboardLayoutImportError() + + data class InvalidKeyPlacement( + val layoutIndex: Int, + val keyIndex: Int, + val reason: String + ) : KeyboardLayoutImportError() + + data class BrokenOwnerReference( + val layoutIndex: Int, + val keyIndex: Int? = null, + val mappingIndex: Int? = null, + val reason: String + ) : KeyboardLayoutImportError() + + data class ValidationFailed( + val layoutIndex: Int? = null, + val reason: String + ) : KeyboardLayoutImportError() + + data class StorageFailed( + val layoutIndex: Int? = null, + val exceptionClass: String? = null, + val message: String? = null + ) : KeyboardLayoutImportError() +} + +sealed class KeyboardLayoutImportWarning { + data class MissingKeyIdentifierGenerated(val layoutIndex: Int, val keyIndex: Int) : + KeyboardLayoutImportWarning() + + data class MissingLayoutIdentifierGenerated(val layoutIndex: Int) : + KeyboardLayoutImportWarning() + + data class MissingFlickListTreatedAsEmpty(val layoutIndex: Int, val keyIndex: Int) : + KeyboardLayoutImportWarning() + + data class MissingSpacerListTreatedAsEmpty(val layoutIndex: Int) : + KeyboardLayoutImportWarning() + + data class InvalidSpanCorrected( + val layoutIndex: Int, + val itemKind: String, + val itemIndex: Int + ) : KeyboardLayoutImportWarning() + + data class InvalidRowColumnCorrected( + val layoutIndex: Int, + val itemKind: String, + val itemIndex: Int + ) : KeyboardLayoutImportWarning() + + data class LayoutSwitchReferenceCouldNotBeResolved( + val layoutIndex: Int, + val itemKind: String, + val itemIndex: Int + ) : KeyboardLayoutImportWarning() + + data class UnknownOptionalFieldIgnored( + val layoutIndex: Int, + val fieldName: String + ) : KeyboardLayoutImportWarning() +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt new file mode 100644 index 000000000..1d1d3a772 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt @@ -0,0 +1,137 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyWithFlicks + +/** + * カスタムキーボードレイアウトを schemaVersion 付き object 形式の JSON に + * シリアライズする exporter。 + * + * 重要: + * - Room の Relation 用モデル [FullKeyboardLayout] を直接 Gson.toJson に + * 渡さないようにすることで、Room モデルを変更しても export 形式に影響を + * 与えにくくする(逆も然り)。 + * - 出力は常に schemaVersion = [KeyboardLayoutJsonImporter.LATEST_SCHEMA_VERSION] + * の object 形式。 + */ +object KeyboardLayoutJsonExporter { + + private val gson: Gson by lazy { + GsonBuilder() + .disableHtmlEscaping() + .serializeNulls() + .create() + } + + /** + * DB から取得した [FullKeyboardLayout] のリストを JSON 文字列に変換する。 + */ + fun toJson(fullLayouts: List): String { + val fileDto = KeyboardLayoutExportFileDto( + schemaVersion = KeyboardLayoutJsonImporter.LATEST_SCHEMA_VERSION, + layouts = fullLayouts.map { it.toExportDto() } + ) + return gson.toJson(fileDto) + } +} + +/** + * Room モデル [FullKeyboardLayout] を export 用 DTO に変換する。 + * + * - キー単位の flick 系 List は順序を維持したままコピー。 + * - spacers も維持。 + */ +internal fun FullKeyboardLayout.toExportDto(): KeyboardLayoutExportDto { + return KeyboardLayoutExportDto( + layout = KeyboardLayoutDto( + layoutId = this.layout.layoutId, + name = this.layout.name, + columnCount = this.layout.columnCount, + rowCount = this.layout.rowCount, + isRomaji = this.layout.isRomaji, + isDirectMode = this.layout.isDirectMode, + createdAt = this.layout.createdAt, + sortOrder = this.layout.sortOrder, + stableId = this.layout.stableId + ), + keysWithFlicks = this.keysWithFlicks.map { it.toExportDto() }, + spacers = this.spacers.map { + SpacerDefinitionDto( + spacerId = it.spacerId, + ownerLayoutId = it.ownerLayoutId, + itemIdentifier = it.itemIdentifier, + rowUnits = it.rowUnits, + columnUnits = it.columnUnits, + rowSpanUnits = it.rowSpanUnits, + columnSpanUnits = it.columnSpanUnits, + sortOrder = it.sortOrder + ) + } + ) +} + +internal fun KeyWithFlicks.toExportDto(): KeyWithFlicksExportDto { + return KeyWithFlicksExportDto( + key = KeyDefinitionDto( + keyId = this.key.keyId, + ownerLayoutId = this.key.ownerLayoutId, + keyIdentifier = this.key.keyIdentifier, + label = this.key.label, + row = this.key.row, + column = this.key.column, + rowSpan = this.key.rowSpan, + colSpan = this.key.colSpan, + keyType = this.key.keyType.name, + isSpecialKey = this.key.isSpecialKey, + drawableResId = this.key.drawableResId, + action = this.key.action, + rowUnits = this.key.rowUnits, + columnUnits = this.key.columnUnits, + rowSpanUnits = this.key.rowSpanUnits, + columnSpanUnits = this.key.columnSpanUnits + ), + flicks = this.flicks.map { + FlickMappingDto( + ownerKeyId = it.ownerKeyId, + stateIndex = it.stateIndex, + flickDirection = it.flickDirection.name, + actionType = it.actionType, + actionValue = it.actionValue + ) + }, + circularFlicks = this.circularFlicks.map { + CircularFlickMappingDto( + ownerKeyId = it.ownerKeyId, + stateIndex = it.stateIndex, + circularDirection = it.circularDirection.name, + actionType = it.actionType, + actionValue = it.actionValue + ) + }, + twoStepFlicks = this.twoStepFlicks.map { + TwoStepFlickMappingDto( + ownerKeyId = it.ownerKeyId, + firstDirection = it.firstDirection.name, + secondDirection = it.secondDirection.name, + output = it.output + ) + }, + longPressFlicks = this.longPressFlicks.map { + LongPressFlickMappingDto( + ownerKeyId = it.ownerKeyId, + flickDirection = it.flickDirection.name, + output = it.output + ) + }, + twoStepLongPressFlicks = this.twoStepLongPressFlicks.map { + TwoStepLongPressMappingDto( + ownerKeyId = it.ownerKeyId, + firstDirection = it.firstDirection.name, + secondDirection = it.secondDirection.name, + output = it.output + ) + } + ) +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt new file mode 100644 index 000000000..0553c1fb1 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt @@ -0,0 +1,98 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import java.io.StringReader + +/** + * 外部 JSON 文字列からカスタムキーボードレイアウトを読み込むための importer。 + * + * 担当範囲: + * - JSON の root が array(旧/現行形式) なのか object(schemaVersion 付き新形式) なのかを判定 + * - schemaVersion が無い場合は legacy v0 として扱う + * - 欠損 / null フィールドを empty list / safe default に正規化 + * - 結果として [ImportableKeyboardLayout] を返す + * + * 重要: ここから外側(Repository, ViewModel)では null 防御を意識しなくて良いように、 + * すべての List を non-null にして返す。 + */ +object KeyboardLayoutJsonImporter { + + /** + * 想定する最新の schemaVersion。 + */ + const val LATEST_SCHEMA_VERSION: Int = 1 + + internal val gson: Gson by lazy { + GsonBuilder() + .setLenient() + .create() + } + + /** + * JSON 文字列を parse して [KeyboardLayoutImportResult] を返す。 + * + * 旧形式 JSON でも、欠損フィールド付き JSON でも、可能な限り読み込みを成功させる。 + * parse 失敗は emptyList に潰さず、失敗理由を型で返す。 + */ + fun parse(jsonString: String): KeyboardLayoutImportResult { + return when (val parsed = parseDtos(jsonString)) { + is KeyboardLayoutJsonParseResult.Success -> + KeyboardBackupNormalizer.normalize(parsed.layouts) + + is KeyboardLayoutJsonParseResult.Failure -> + KeyboardLayoutImportResult.Failure(parsed.error) + } + } + + internal fun parseDtos(jsonString: String): KeyboardLayoutJsonParseResult { + val sanitized = KeyboardLayoutBackupFormatDetector.sanitize(jsonString) + + if (sanitized.isBlank()) { + return KeyboardLayoutJsonParseResult.Failure(KeyboardLayoutImportError.EmptyInput) + } + + val root: JsonElement = try { + JsonParser.parseReader( + JsonReader(StringReader(sanitized)).apply { isLenient = true } + ) + } catch (e: Exception) { + return KeyboardLayoutJsonParseResult.Failure( + KeyboardLayoutImportError.MalformedJson( + exceptionClass = e::class.java.simpleName, + message = e.message + ) + ) + } + + return try { + KeyboardBackupParser.parse(root, gson) + } catch (e: Exception) { + KeyboardLayoutJsonParseResult.Failure( + KeyboardLayoutImportError.MalformedJson( + exceptionClass = e::class.java.simpleName, + message = e.message + ) + ) + } + } + + internal fun looksLikeLayoutBackup(jsonString: String): Boolean { + val sanitized = KeyboardLayoutBackupFormatDetector.sanitize(jsonString) + if (sanitized.isBlank()) return false + return runCatching { + val root = JsonParser.parseReader( + JsonReader(StringReader(sanitized)).apply { isLenient = true } + ) + KeyboardBackupValidator.isLayoutBackupRoot(root) + }.getOrDefault(false) + } +} + +sealed class KeyboardLayoutJsonParseResult { + data class Success(val layouts: List) : KeyboardLayoutJsonParseResult() + data class Failure(val error: KeyboardLayoutImportError) : KeyboardLayoutJsonParseResult() +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/EditableFlickKeyboardView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/EditableFlickKeyboardView.kt index 17f098c48..f009475b9 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/EditableFlickKeyboardView.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/EditableFlickKeyboardView.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.ClipData import android.content.Context import android.graphics.Color +import android.graphics.drawable.GradientDrawable import android.text.Spannable import android.text.SpannableString import android.text.style.AbsoluteSizeSpan @@ -15,13 +16,17 @@ import android.view.View import android.view.View.OnDragListener import android.widget.GridLayout import android.widget.ImageButton +import android.widget.TextView import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageButton import androidx.core.content.ContextCompat import com.kazumaproject.core.domain.extensions.isDarkThemeOn +import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.SpacerItem import com.kazumaproject.custom_keyboard.layout.SegmentedBackgroundDrawable import com.kazumaproject.custom_keyboard.view.AutoSizeButton import com.google.android.material.R as MaterialR @@ -34,6 +39,7 @@ class EditableFlickKeyboardView @JvmOverloads constructor( // ▼▼▼ インターフェースに削除イベントを追加 ▼▼▼ interface OnKeyEditListener { fun onKeySelected(keyId: String) + fun onSpacerSelected(spacerId: String) fun onKeysSwapped(draggedKeyId: String, targetKeyId: String) fun onRowDeleted(rowIndex: Int) fun onColumnDeleted(columnIndex: Int) @@ -55,8 +61,10 @@ class EditableFlickKeyboardView @JvmOverloads constructor( this.removeAllViews() // ▼▼▼ 削除ボタン用に列と行を1つずつ増やす ▼▼▼ - this.columnCount = layout.columnCount + 1 - this.rowCount = layout.rowCount + 1 + val keyboardColumnUnits = if (layout.items.isNotEmpty()) layout.columnUnitCount else layout.columnCount + val keyboardRowUnits = if (layout.items.isNotEmpty()) layout.rowUnitCount else layout.rowCount + this.columnCount = keyboardColumnUnits + 2 + this.rowCount = keyboardRowUnits + 2 // ▲▲▲ 削除ボタン用に列と行を1つずつ増やす ▲▲▲ this.isFocusable = false @@ -64,24 +72,29 @@ class EditableFlickKeyboardView @JvmOverloads constructor( val dragListener = createDragListener() // キーの描画 - layout.keys.forEach { keyData -> - // ▼▼▼ 削除ボタン用にオフセット(1,1)をかけて描画 ▼▼▼ - val keyView: View = createKeyView(keyData, 1, 1) - keyView.tag = keyData.keyId - keyView.setOnDragListener(dragListener) - keyView.setOnClickListener { listener?.onKeySelected(keyData.keyId!!) } - keyView.setOnLongClickListener { view -> - keyData.keyId?.let { keyId -> - val clipText = "keyId:$keyId" - val item = ClipData.Item(clipText) - val mimeTypes = arrayOf("text/plain") - val data = ClipData(clipText, mimeTypes, item) - val dragShadow = DragShadowBuilder(view) - view.startDragAndDrop(data, dragShadow, view, 0) + if (layout.items.isNotEmpty()) { + layout.items.forEach { item -> + when (item) { + is KeyItem -> addKeyItem(item, dragListener) + is SpacerItem -> addSpacerItem(item) } - true } - this.addView(keyView) + } else { + layout.keys.forEach { keyData -> + addKeyItem( + KeyItem( + id = keyData.keyId ?: "legacy_${keyData.row}_${keyData.column}_${keyData.label}", + keyData = keyData, + placement = GridPlacement( + rowUnits = keyData.row, + columnUnits = keyData.column, + rowSpanUnits = keyData.rowSpan, + columnSpanUnits = keyData.colSpan + ) + ), + dragListener + ) + } } // ▼▼▼ ここから削除ボタンの描画を追加 ▼▼▼ @@ -89,8 +102,8 @@ class EditableFlickKeyboardView @JvmOverloads constructor( for (i in 0 until layout.rowCount) { val deleteButton = createDeleteButton { listener?.onRowDeleted(i) } val params = LayoutParams().apply { - rowSpec = spec(i + 1, 1, FILL, 1f) // キーの行に対応 (+1はオフセット) - columnSpec = spec(0, 1, FILL, 0.5f) // 最初の列(インデックス0)に配置 + rowSpec = spec(i * 2 + 2, 2, FILL, 1f) // キーの行に対応 (+2 units はオフセット) + columnSpec = spec(0, 2, FILL, 0.5f) // 最初の列(インデックス0)に配置 width = 0 height = 0 setMargins(dpToPx(4), dpToPx(4), dpToPx(4), dpToPx(4)) @@ -103,8 +116,8 @@ class EditableFlickKeyboardView @JvmOverloads constructor( for (i in 0 until layout.columnCount) { val deleteButton = createDeleteButton { listener?.onColumnDeleted(i) } val params = LayoutParams().apply { - rowSpec = spec(0, 1, FILL, 0.5f) // 最初の行(インデックス0)に配置 - columnSpec = spec(i + 1, 1, FILL, 1f) // キーの列に対応 (+1はオフセット) + rowSpec = spec(0, 2, FILL, 0.5f) // 最初の行(インデックス0)に配置 + columnSpec = spec(i * 2 + 2, 2, FILL, 1f) // キーの列に対応 (+2 units はオフセット) width = 0 height = 0 setMargins(dpToPx(4), dpToPx(4), dpToPx(4), dpToPx(4)) @@ -115,6 +128,49 @@ class EditableFlickKeyboardView @JvmOverloads constructor( // ▲▲▲ ここまで削除ボタンの描画を追加 ▲▲▲ } + private fun addKeyItem(item: KeyItem, dragListener: OnDragListener) { + val keyData = item.keyData + val keyView: View = createKeyView(keyData) + keyView.layoutParams = createLayoutParams(item.placement, rowOffsetUnits = 2, columnOffsetUnits = 2) + keyView.tag = keyData.keyId + keyView.setOnDragListener(dragListener) + keyView.setOnClickListener { + keyData.keyId?.let { keyId -> listener?.onKeySelected(keyId) } + } + keyView.setOnLongClickListener { view -> + keyData.keyId?.let { keyId -> + val clipText = "keyId:$keyId" + val clipItem = ClipData.Item(clipText) + val mimeTypes = arrayOf("text/plain") + val data = ClipData(clipText, mimeTypes, clipItem) + val dragShadow = DragShadowBuilder(view) + view.startDragAndDrop(data, dragShadow, view, 0) + } + true + } + this.addView(keyView) + } + + private fun addSpacerItem(item: SpacerItem) { + val spacerView = TextView(context).apply { + isClickable = true + isFocusable = false + text = "" + background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dpToPx(4).toFloat() + setColor(Color.argb(28, 96, 125, 139)) + setStroke(dpToPx(1), Color.argb(150, 96, 125, 139), dpToPx(4).toFloat(), dpToPx(3).toFloat()) + } + contentDescription = "Spacer" + setOnClickListener { + listener?.onSpacerSelected(item.id) + } + } + spacerView.layoutParams = createLayoutParams(item.placement, rowOffsetUnits = 2, columnOffsetUnits = 2) + this.addView(spacerView) + } + // ▼▼▼ 削除ボタンを生成するヘルパー関数を追加 ▼▼▼ private fun createDeleteButton(onClick: () -> Unit): ImageButton { return ImageButton( @@ -164,11 +220,32 @@ class EditableFlickKeyboardView @JvmOverloads constructor( } } - // createKeyViewにオフセット引数を追加 + private fun createLayoutParams( + placement: GridPlacement, + rowOffsetUnits: Int, + columnOffsetUnits: Int + ): LayoutParams { + return LayoutParams().apply { + rowSpec = spec( + placement.rowUnits + rowOffsetUnits, + placement.rowSpanUnits, + FILL, + placement.rowSpanUnits.toFloat() + ) + columnSpec = spec( + placement.columnUnits + columnOffsetUnits, + placement.columnSpanUnits, + FILL, + placement.columnSpanUnits.toFloat() + ) + width = 0 + height = 0 + elevation = 2f + } + } + private fun createKeyView( - keyData: KeyData, - rowOffset: Int, - colOffset: Int + keyData: KeyData ): View { val isDarkTheme = context.isDarkThemeOn() @@ -282,17 +359,6 @@ class EditableFlickKeyboardView @JvmOverloads constructor( } } - val params = LayoutParams().apply { - rowSpec = spec(keyData.row + rowOffset, keyData.rowSpan, FILL, 1f) - columnSpec = spec(keyData.column + colOffset, keyData.colSpan, FILL, 1f) - width = 0 - height = 0 - elevation = 2f - // ▼▼▼ 変更点4: setMargins の呼び出しを完全に削除 ▼▼▼ - // if (keyData.isSpecialKey) setMargins(6, 12, 6, 6) - // else setMargins(6, 9, 6, 9) - } - keyView.layoutParams = params return keyView } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt index 941d75bbf..d88cdefe5 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt @@ -626,6 +626,14 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { isUpdatingCharEditText = false } + private fun textOutputFromAction(action: KeyAction?): String { + return when (action) { + is KeyAction.Text -> action.text + is KeyAction.InputText -> action.text + else -> "" + } + } + private fun tfbiToDisplayName(dir: TfbiFlickDirection): String = FlickDirectionMapper.toDisplayName(dir, requireContext()) @@ -925,7 +933,13 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { val flickMap = state.layout.flickKeyMaps[key.keyId]?.firstOrNull() ?: emptyMap() currentFlickItems = FlickDirectionMapper.allowedDirections.map { direction -> val savedAction = flickMap[direction] - val output = if (savedAction is FlickAction.Input) savedAction.char else "" + val output = if (savedAction is FlickAction.Input) { + savedAction.char + } else if (direction == FlickDirection.TAP) { + textOutputFromAction(key.action) + } else { + "" + } FlickMappingItem(direction = direction, output = output) }.toMutableList() @@ -1000,7 +1014,7 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { val newLabel: String val newKeyType: KeyType val isSpecial: Boolean - val newAction: KeyAction? + var newAction: KeyAction? var newFlickMap: Map = emptyMap() var newCircularFlickMaps: List> = emptyList() var newTwoStepMap: Map> = emptyMap() @@ -1115,6 +1129,14 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { if (isCircular) { newKeyType = KeyType.CIRCULAR_FLICK newLabel = binding.keyLabelEdittext.text.toString() + val tapOutput = currentCircularFlickMaps + .firstOrNull() + ?.firstOrNull { it.direction == CircularFlickDirection.TAP } + ?.output + .orEmpty() + newAction = tapOutput + .takeIf { it.isNotBlank() } + ?.let { KeyAction.Text(it) } newCircularFlickMaps = currentCircularFlickMaps.map { items -> items .mapNotNull { item -> @@ -1132,11 +1154,32 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { newLongPressFlickMap = emptyMap() newTwoStepLongPressMap = emptyMap() } else if (!isTwoStep) { - newKeyType = KeyType.PETAL_FLICK newLabel = binding.keyLabelEdittext.text.toString() - newFlickMap = currentFlickItems + val tapOutput = currentFlickItems + .firstOrNull { it.direction == FlickDirection.TAP } + ?.output + .orEmpty() + val nonTapFlickItems = currentFlickItems + .filter { it.direction != FlickDirection.TAP && it.output.isNotEmpty() } + newKeyType = if ( + originalKey.keyType == KeyType.NORMAL && + nonTapFlickItems.isEmpty() && + currentLongPressFlickItems.none { it.output.isNotEmpty() } + ) { + KeyType.NORMAL + } else { + KeyType.PETAL_FLICK + } + newAction = tapOutput + .takeIf { it.isNotBlank() } + ?.let { KeyAction.Text(it) } + newFlickMap = if (newKeyType == KeyType.NORMAL) { + emptyMap() + } else { + currentFlickItems .filter { it.output.isNotEmpty() } .associate { it.direction to FlickAction.Input(it.output) } + } newLongPressFlickMap = currentLongPressFlickItems .filter { it.output.isNotEmpty() } .associate { it.direction to it.output } @@ -1150,6 +1193,9 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { ?.output.orEmpty() val configuredLabel = binding.keyLabelEdittext.text.toString().trim() newLabel = configuredLabel.ifEmpty { base } + newAction = base + .takeIf { it.isNotBlank() } + ?.let { KeyAction.Text(it) } newTwoStepMap = buildTwoStepOutputMap( items = currentTwoStepItems, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorFragment.kt index e7f70c025..a24797c45 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorFragment.kt @@ -1,10 +1,14 @@ package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.ui import android.os.Bundle +import android.text.InputType import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.view.MenuHost import androidx.core.view.MenuProvider @@ -18,6 +22,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.ui.view.EditableFlickKeyboardView import com.kazumaproject.markdownhelperkeyboard.databinding.FragmentKeyboardEditorBinding @@ -184,6 +189,10 @@ class KeyboardEditorFragment : Fragment(R.layout.fragment_keyboard_editor), findNavController().navigate(R.id.action_keyboardEditorFragment_to_keyEditorFragment) } + override fun onSpacerSelected(spacerId: String) { + Timber.d("onSpacerSelected: spacerId = $spacerId") + } + override fun onKeysSwapped(draggedKeyId: String, targetKeyId: String) { Timber.d("onKeysSwapped: dragged=$draggedKeyId, target=$targetKeyId") viewModel.swapKeys(draggedKeyId, targetKeyId) @@ -199,6 +208,49 @@ class KeyboardEditorFragment : Fragment(R.layout.fragment_keyboard_editor), viewModel.deleteColumnAt(columnIndex) } + private fun readSpacerPlacement( + rowEdit: EditText, + columnEdit: EditText, + widthEdit: EditText, + heightEdit: EditText + ): GridPlacement? { + val rowUnits = halfCellUnits(rowEdit.text.toString()) ?: return null + val columnUnits = halfCellUnits(columnEdit.text.toString()) ?: return null + val columnSpanUnits = halfCellUnits(widthEdit.text.toString()) ?: return null + val rowSpanUnits = halfCellUnits(heightEdit.text.toString()) ?: return null + return GridPlacement(rowUnits, columnUnits, rowSpanUnits, columnSpanUnits) + } + + private fun halfCellUnits(value: String): Int? { + val parsed = value.toFloatOrNull() ?: return null + val unitsFloat = parsed * 2f + val units = kotlin.math.round(unitsFloat).toInt() + if (kotlin.math.abs(unitsFloat - units) > 0.001f) return null + return units + } + + private fun decimalEditText(value: String): EditText { + return EditText(requireContext()).apply { + inputType = InputType.TYPE_CLASS_NUMBER or + InputType.TYPE_NUMBER_FLAG_DECIMAL or + InputType.TYPE_NUMBER_FLAG_SIGNED + setSingleLine(true) + setText(value) + } + } + + private fun labeledView(label: String, child: View): View { + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(context).apply { text = label }) + addView(child) + } + } + + private fun dpToPx(dp: Int): Int { + return (dp * resources.displayMetrics.density).toInt() + } + override fun onDestroyView() { super.onDestroyView() (activity as? AppCompatActivity)?.supportActionBar?.apply { 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 1750cb6fa..75105f7e8 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 @@ -5,12 +5,24 @@ import androidx.lifecycle.viewModelScope import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.KeyboardLayoutItem +import com.kazumaproject.custom_keyboard.data.SpacerItem +import com.kazumaproject.custom_keyboard.data.copyWithItems +import com.kazumaproject.custom_keyboard.data.copyWithKeys +import com.kazumaproject.custom_keyboard.data.hasPlacementIssues +import com.kazumaproject.custom_keyboard.data.swapKeyPlacements +import com.kazumaproject.custom_keyboard.data.usesFlexiblePlacement import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.ImportableKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportResult import com.kazumaproject.markdownhelperkeyboard.repository.KeyboardRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -59,6 +71,10 @@ class KeyboardEditorViewModel @Inject constructor( isUpperCase = false ) ), + LayoutTemplate( + "QWERTY", + KeyboardDefaultLayouts.createQwertyTemplateLayout() + ), LayoutTemplate("数字入力", KeyboardDefaultLayouts.createNumberTemplateLayout()) ) @@ -159,11 +175,19 @@ class KeyboardEditorViewModel @Inject constructor( _uiState.update { currentState -> val layout = currentState.layout val newRowCount = layout.rowCount + 1 + if (layout.usesFlexiblePlacement()) { + return@update currentState.copy( + layout = layout.copy( + rowCount = newRowCount, + rowUnitCount = layout.rowUnitCount + 2 + ) + ) + } val newKeys = layout.keys.toMutableList() for (col in 0 until layout.columnCount) { newKeys.add(createEmptyKey(newRowCount - 1, col)) } - val newLayout = layout.copy(keys = newKeys, rowCount = newRowCount) + val newLayout = layout.copyWithKeys(newKeys, rowCount = newRowCount) currentState.copy(layout = newLayout) } } @@ -173,6 +197,20 @@ class KeyboardEditorViewModel @Inject constructor( val layout = currentState.layout if (layout.rowCount <= 1) return@update currentState val newRowCount = layout.rowCount - 1 + if (layout.usesFlexiblePlacement()) { + val newRowUnitCount = newRowCount * 2 + val newItems = trimItemsToBounds( + items = layout.items, + rowUnitCount = newRowUnitCount, + columnUnitCount = layout.columnUnitCount + ) + return@update currentState.copy( + layout = layout.copy( + rowCount = newRowCount, + rowUnitCount = newRowUnitCount + ).copyWithItems(newItems) + ) + } val updatedKeys = layout.keys.mapNotNull { key -> if (key.row >= newRowCount) { @@ -185,7 +223,7 @@ class KeyboardEditorViewModel @Inject constructor( } } - val newLayout = layout.copy(keys = updatedKeys, rowCount = newRowCount) + val newLayout = layout.copyWithKeys(updatedKeys, rowCount = newRowCount) currentState.copy(layout = newLayout) } } @@ -194,11 +232,19 @@ class KeyboardEditorViewModel @Inject constructor( _uiState.update { currentState -> val layout = currentState.layout val newColumnCount = layout.columnCount + 1 + if (layout.usesFlexiblePlacement()) { + return@update currentState.copy( + layout = layout.copy( + columnCount = newColumnCount, + columnUnitCount = layout.columnUnitCount + 2 + ) + ) + } val newKeys = layout.keys.toMutableList() for (row in 0 until layout.rowCount) { newKeys.add(createEmptyKey(row, newColumnCount - 1)) } - val newLayout = layout.copy( + val newLayout = layout.copyWithKeys( keys = newKeys.sortedWith(compareBy({ it.row }, { it.column })), columnCount = newColumnCount ) @@ -211,6 +257,20 @@ class KeyboardEditorViewModel @Inject constructor( val layout = currentState.layout if (layout.columnCount <= 1) return@update currentState val newColumnCount = layout.columnCount - 1 + if (layout.usesFlexiblePlacement()) { + val newColumnUnitCount = newColumnCount * 2 + val newItems = trimItemsToBounds( + items = layout.items, + rowUnitCount = layout.rowUnitCount, + columnUnitCount = newColumnUnitCount + ) + return@update currentState.copy( + layout = layout.copy( + columnCount = newColumnCount, + columnUnitCount = newColumnUnitCount + ).copyWithItems(newItems) + ) + } val updatedKeys = layout.keys.mapNotNull { key -> if (key.column >= newColumnCount) { @@ -223,7 +283,7 @@ class KeyboardEditorViewModel @Inject constructor( } } - val newLayout = layout.copy(keys = updatedKeys, columnCount = newColumnCount) + val newLayout = layout.copyWithKeys(updatedKeys, columnCount = newColumnCount) currentState.copy(layout = newLayout) } } @@ -232,6 +292,23 @@ class KeyboardEditorViewModel @Inject constructor( _uiState.update { currentState -> val layout = currentState.layout if (layout.rowCount <= 1) return@update currentState + if (layout.usesFlexiblePlacement()) { + val deleteStart = rowIndex * 2 + val deleteEnd = deleteStart + 2 + val newItems = deleteUnitRange( + items = layout.items, + startUnits = deleteStart, + endUnits = deleteEnd, + isRow = true + ) + val newRowCount = layout.rowCount - 1 + return@update currentState.copy( + layout = layout.copy( + rowCount = newRowCount, + rowUnitCount = newRowCount * 2 + ).copyWithItems(newItems) + ) + } val updatedKeys = layout.keys.mapNotNull { key -> val keyRowStart = key.row @@ -248,7 +325,7 @@ class KeyboardEditorViewModel @Inject constructor( } } - val newLayout = layout.copy(keys = updatedKeys, rowCount = layout.rowCount - 1) + val newLayout = layout.copyWithKeys(updatedKeys, rowCount = layout.rowCount - 1) currentState.copy(layout = newLayout) } } @@ -257,6 +334,23 @@ class KeyboardEditorViewModel @Inject constructor( _uiState.update { currentState -> val layout = currentState.layout if (layout.columnCount <= 1) return@update currentState + if (layout.usesFlexiblePlacement()) { + val deleteStart = columnIndex * 2 + val deleteEnd = deleteStart + 2 + val newItems = deleteUnitRange( + items = layout.items, + startUnits = deleteStart, + endUnits = deleteEnd, + isRow = false + ) + val newColumnCount = layout.columnCount - 1 + return@update currentState.copy( + layout = layout.copy( + columnCount = newColumnCount, + columnUnitCount = newColumnCount * 2 + ).copyWithItems(newItems) + ) + } val updatedKeys = layout.keys.mapNotNull { key -> val keyColStart = key.column @@ -273,7 +367,7 @@ class KeyboardEditorViewModel @Inject constructor( } } - val newLayout = layout.copy(keys = updatedKeys, columnCount = layout.columnCount - 1) + val newLayout = layout.copyWithKeys(updatedKeys, columnCount = layout.columnCount - 1) currentState.copy(layout = newLayout) } } @@ -317,6 +411,60 @@ class KeyboardEditorViewModel @Inject constructor( val layout = currentState.layout val oldKeyData = layout.keys.find { it.keyId == keyId } ?: return@update currentState + val finalFlickMaps = layout.flickKeyMaps.toMutableMap() + val finalCircularFlickMaps = layout.circularFlickKeyMaps.toMutableMap() + val finalTwoStepMaps = layout.twoStepFlickKeyMaps.toMutableMap() + val finalLongPressFlickMaps = layout.longPressFlickKeyMaps.toMutableMap() + val finalTwoStepLongPressMaps = layout.twoStepLongPressKeyMaps.toMutableMap() + + applyUpdatedMappings( + keyId = keyId, + newKeyData = newKeyData, + flickMap = flickMap, + twoStepMap = twoStepMap, + longPressFlickMap = longPressFlickMap, + twoStepLongPressMap = twoStepLongPressMap, + circularFlickMaps = circularFlickMaps, + finalFlickMaps = finalFlickMaps, + finalCircularFlickMaps = finalCircularFlickMaps, + finalTwoStepMaps = finalTwoStepMaps, + finalLongPressFlickMaps = finalLongPressFlickMaps, + finalTwoStepLongPressMaps = finalTwoStepLongPressMaps + ) + + if (layout.usesFlexiblePlacement()) { + val oldItem = layout.items.filterIsInstance().firstOrNull { + it.id == keyId || it.keyData.keyId == keyId + } ?: return@update currentState + + val updatedItems = layout.items.map { item -> + if (item is KeyItem && item.id == oldItem.id) { + item.copy( + keyData = newKeyData, + placement = item.placement.copy( + rowSpanUnits = newKeyData.rowSpan * 2, + columnSpanUnits = newKeyData.colSpan * 2 + ) + ) + } else { + item + } + } + + if (hasPlacementIssues(updatedItems, layout.rowUnitCount, layout.columnUnitCount)) { + return@update currentState + } + + val newLayout = layout.copyWithItems(updatedItems).copy( + flickKeyMaps = finalFlickMaps, + circularFlickKeyMaps = finalCircularFlickMaps, + twoStepFlickKeyMaps = finalTwoStepMaps, + longPressFlickKeyMaps = finalLongPressFlickMaps, + twoStepLongPressKeyMaps = finalTwoStepLongPressMaps + ) + return@update currentState.copy(layout = newLayout) + } + val otherKeys = layout.keys.filter { it.keyId != keyId } val crushedKeys = otherKeys.filter { isRectOverlapping(newKeyData, it) } @@ -333,12 +481,6 @@ class KeyboardEditorViewModel @Inject constructor( .plus(newKeyData) .plus(newEmptyKeys) - val finalFlickMaps = layout.flickKeyMaps.toMutableMap() - val finalCircularFlickMaps = layout.circularFlickKeyMaps.toMutableMap() - val finalTwoStepMaps = layout.twoStepFlickKeyMaps.toMutableMap() - val finalLongPressFlickMaps = layout.longPressFlickKeyMaps.toMutableMap() - val finalTwoStepLongPressMaps = layout.twoStepLongPressKeyMaps.toMutableMap() - crushedKeyIds.forEach { finalFlickMaps.remove(it) finalCircularFlickMaps.remove(it) @@ -347,45 +489,7 @@ class KeyboardEditorViewModel @Inject constructor( finalTwoStepLongPressMaps.remove(it) } - when (newKeyData.keyType) { - KeyType.TWO_STEP_FLICK -> { - // 2段フリックに切り替えた場合、1段フリック設定を消して2段を保存 - finalFlickMaps.remove(keyId) - finalCircularFlickMaps.remove(keyId) - finalLongPressFlickMaps.remove(keyId) - finalTwoStepMaps[keyId] = twoStepMap - if (twoStepLongPressMap.isNotEmpty()) { - finalTwoStepLongPressMaps[keyId] = twoStepLongPressMap - } else { - finalTwoStepLongPressMaps.remove(keyId) - } - } - - KeyType.CIRCULAR_FLICK -> { - finalFlickMaps.remove(keyId) - finalTwoStepMaps.remove(keyId) - finalTwoStepLongPressMaps.remove(keyId) - finalLongPressFlickMaps.remove(keyId) - finalCircularFlickMaps[keyId] = - circularFlickMaps.ifEmpty { listOf(emptyMap()) } - } - - else -> { - // 1段フリック系の場合、2段フリック設定を消して1段を保存 - finalTwoStepMaps.remove(keyId) - finalTwoStepLongPressMaps.remove(keyId) - finalCircularFlickMaps.remove(keyId) - finalFlickMaps[keyId] = listOf(flickMap) - if (longPressFlickMap.isNotEmpty()) { - finalLongPressFlickMaps[keyId] = longPressFlickMap - } else { - finalLongPressFlickMaps.remove(keyId) - } - } - } - - val newLayout = layout.copy( - keys = finalKeys, + val newLayout = layout.copyWithKeys(finalKeys).copy( flickKeyMaps = finalFlickMaps, circularFlickKeyMaps = finalCircularFlickMaps, twoStepFlickKeyMaps = finalTwoStepMaps, @@ -396,6 +500,56 @@ class KeyboardEditorViewModel @Inject constructor( } } + private fun applyUpdatedMappings( + keyId: String, + newKeyData: KeyData, + flickMap: Map, + twoStepMap: Map>, + longPressFlickMap: Map, + twoStepLongPressMap: Map>, + circularFlickMaps: List>, + finalFlickMaps: MutableMap>>, + finalCircularFlickMaps: MutableMap>>, + finalTwoStepMaps: MutableMap>>, + finalLongPressFlickMaps: MutableMap>, + finalTwoStepLongPressMaps: MutableMap>> + ) { + when (newKeyData.keyType) { + KeyType.TWO_STEP_FLICK -> { + finalFlickMaps.remove(keyId) + finalCircularFlickMaps.remove(keyId) + finalLongPressFlickMaps.remove(keyId) + finalTwoStepMaps[keyId] = twoStepMap + if (twoStepLongPressMap.isNotEmpty()) { + finalTwoStepLongPressMaps[keyId] = twoStepLongPressMap + } else { + finalTwoStepLongPressMaps.remove(keyId) + } + } + + KeyType.CIRCULAR_FLICK -> { + finalFlickMaps.remove(keyId) + finalTwoStepMaps.remove(keyId) + finalTwoStepLongPressMaps.remove(keyId) + finalLongPressFlickMaps.remove(keyId) + finalCircularFlickMaps[keyId] = + circularFlickMaps.ifEmpty { listOf(emptyMap()) } + } + + else -> { + finalTwoStepMaps.remove(keyId) + finalTwoStepLongPressMaps.remove(keyId) + finalCircularFlickMaps.remove(keyId) + finalFlickMaps[keyId] = listOf(flickMap) + if (longPressFlickMap.isNotEmpty()) { + finalLongPressFlickMaps[keyId] = longPressFlickMap + } else { + finalLongPressFlickMaps.remove(keyId) + } + } + } + } + private fun getOccupiedCells(key: KeyData): List> { val cells = mutableListOf>() for (r in key.row until key.row + key.rowSpan) { @@ -406,72 +560,97 @@ class KeyboardEditorViewModel @Inject constructor( return cells } + /** + * Drag-swap two KeyItems by exchanging their [GridPlacement]s. + * + * Source of truth is `layout.items` + `GridPlacement` — KeyData.row / + * KeyData.column are NOT rewritten here, because they cannot represent + * half-cell offsets used by QWERTY-family templates and would corrupt + * the visual placement. + * + * The swap is rejected (no-op) if it would produce overlaps or + * out-of-bounds placements. + */ fun swapKeys(draggedKeyId: String, targetKeyId: String) { _uiState.update { currentState -> val layout = currentState.layout - val currentKeys = layout.keys - - val draggedKey = - currentKeys.find { it.keyId == draggedKeyId } ?: return@update currentState - val targetKey = - currentKeys.find { it.keyId == targetKeyId } ?: return@update currentState - if (draggedKeyId == targetKeyId) return@update currentState - val destRow = targetKey.row - val destCol = targetKey.column - val movedDraggedKey = draggedKey.copy(row = destRow, column = destCol) - - val victims = currentKeys.filter { key -> - key.keyId != draggedKeyId && isRectOverlapping(movedDraggedKey, key) - } - - val moveRowDelta = destRow - draggedKey.row - val moveColDelta = destCol - draggedKey.column - - val newKeysCandidate = currentKeys.map { key -> - when { - key.keyId == draggedKeyId -> movedDraggedKey - victims.any { it.keyId == key.keyId } -> { - key.copy( - row = key.row - moveRowDelta, - column = key.column - moveColDelta - ) - } + val swapped = layout.swapKeyPlacements(draggedKeyId, targetKeyId) + // swapKeyPlacements returns the original layout if the swap is + // invalid (overlap, out of bounds, missing id). + if (swapped === layout) return@update currentState + currentState.copy(layout = swapped) + } + } - else -> key - } - } + fun addSpacer( + rowUnits: Int, + columnUnits: Int, + rowSpanUnits: Int, + columnSpanUnits: Int + ): Boolean { + val currentState = _uiState.value + val layout = currentState.layout + val spacer = SpacerItem( + id = "spacer_${UUID.randomUUID()}", + placement = GridPlacement( + rowUnits = rowUnits, + columnUnits = columnUnits, + rowSpanUnits = rowSpanUnits, + columnSpanUnits = columnSpanUnits + ) + ) + val updatedItems = layout.items + spacer + if (!isValidPlacementUpdate(layout, updatedItems)) return false + _uiState.value = currentState.copy(layout = layout.copyWithItems(updatedItems)) + return true + } - if (hasIssues(newKeysCandidate, layout.rowCount, layout.columnCount)) { - return@update currentState + fun updateSpacerPlacement(spacerId: String, placement: GridPlacement): Boolean { + val currentState = _uiState.value + val layout = currentState.layout + var found = false + val updatedItems = layout.items.map { item -> + if (item is SpacerItem && item.id == spacerId) { + found = true + item.copy(placement = placement) + } else { + item } - - val newLayout = currentState.layout.copy(keys = newKeysCandidate) - currentState.copy(layout = newLayout) } + if (!found || !isValidPlacementUpdate(layout, updatedItems)) return false + _uiState.value = currentState.copy(layout = layout.copyWithItems(updatedItems)) + return true } - private fun hasIssues(keys: List, rowCount: Int, colCount: Int): Boolean { - keys.forEach { key -> - if (key.row < 0 || key.column < 0 || - key.row + key.rowSpan > rowCount || - key.column + key.colSpan > colCount - ) { - return true - } - } + fun deleteSpacer(spacerId: String): Boolean { + val currentState = _uiState.value + val layout = currentState.layout + val updatedItems = layout.items.filterNot { it is SpacerItem && it.id == spacerId } + if (updatedItems.size == layout.items.size) return false + _uiState.value = currentState.copy(layout = layout.copyWithItems(updatedItems)) + return true + } - for (i in keys.indices) { - for (j in i + 1 until keys.size) { - if (isRectOverlapping(keys[i], keys[j])) { - return true - } - } - } - return false + private fun isValidPlacementUpdate( + layout: KeyboardLayout, + items: List + ): Boolean { + return !hasPlacementIssues( + items = items, + rowUnitCount = layout.rowUnitCount, + columnUnitCount = layout.columnUnitCount + ) } + /** + * Cell-grid overlap test for [updateKeyAndMappings] (which works on + * KeyData.row/column for the simple, single-key edit flow). + * + * Drag-swap uses [com.kazumaproject.custom_keyboard.data.hasPlacementIssues] + * on GridPlacement instead — see [swapKeys]. + */ private fun isRectOverlapping(key1: KeyData, key2: KeyData): Boolean { val k1Left = key1.column val k1Right = key1.column + key1.colSpan @@ -489,6 +668,90 @@ class KeyboardEditorViewModel @Inject constructor( k1Top >= k2Bottom) } + private fun trimItemsToBounds( + items: List, + rowUnitCount: Int, + columnUnitCount: Int + ): List { + return items.mapNotNull { item -> + val p = item.placement + if (p.rowUnits >= rowUnitCount || p.columnUnits >= columnUnitCount) { + return@mapNotNull null + } + val newPlacement = p.copy( + rowSpanUnits = minOf(p.rowSpanUnits, rowUnitCount - p.rowUnits), + columnSpanUnits = minOf(p.columnSpanUnits, columnUnitCount - p.columnUnits) + ) + if (newPlacement.rowSpanUnits <= 0 || newPlacement.columnSpanUnits <= 0) { + null + } else { + item.withPlacementAndApproximateKeyData(newPlacement) + } + } + } + + private fun deleteUnitRange( + items: List, + startUnits: Int, + endUnits: Int, + isRow: Boolean + ): List { + val removedUnits = endUnits - startUnits + return items.mapNotNull { item -> + val p = item.placement + val itemStart = if (isRow) p.rowUnits else p.columnUnits + val itemSpan = if (isRow) p.rowSpanUnits else p.columnSpanUnits + val itemEnd = itemStart + itemSpan + + val newStart: Int + val newSpan: Int + when { + itemEnd <= startUnits -> { + newStart = itemStart + newSpan = itemSpan + } + itemStart >= endUnits -> { + newStart = itemStart - removedUnits + newSpan = itemSpan + } + else -> { + val remainingBefore = maxOf(0, startUnits - itemStart) + val remainingAfter = maxOf(0, itemEnd - endUnits) + newStart = if (itemStart < startUnits) itemStart else startUnits + newSpan = remainingBefore + remainingAfter + } + } + + if (newSpan <= 0) { + null + } else { + val newPlacement = if (isRow) { + p.copy(rowUnits = newStart, rowSpanUnits = newSpan) + } else { + p.copy(columnUnits = newStart, columnSpanUnits = newSpan) + } + item.withPlacementAndApproximateKeyData(newPlacement) + } + } + } + + private fun KeyboardLayoutItem.withPlacementAndApproximateKeyData( + placement: GridPlacement + ): KeyboardLayoutItem { + return when (this) { + is SpacerItem -> copy(placement = placement) + is KeyItem -> copy( + keyData = keyData.copy( + row = placement.rowUnits / 2, + column = placement.columnUnits / 2, + rowSpan = (placement.rowSpanUnits + 1) / 2, + colSpan = (placement.columnSpanUnits + 1) / 2 + ), + placement = placement + ) + } + } + fun updateIsRomaji(isRomaji: Boolean) { _uiState.update { it.copy(isRomaji = isRomaji) } } @@ -503,7 +766,17 @@ class KeyboardEditorViewModel @Inject constructor( fun applyTemplate(templateLayout: KeyboardLayout) { val keysWithEnsuredIds = templateLayout.keys.map { key -> - if (key.keyId == null) key.copy(keyId = UUID.randomUUID().toString()) else key + val keyWithId = if (key.keyId == null) key.copy(keyId = UUID.randomUUID().toString()) else key + if ( + !keyWithId.isSpecialKey && + keyWithId.keyType == KeyType.NORMAL && + keyWithId.label.isNotBlank() && + keyWithId.action == null + ) { + keyWithId.copy(action = KeyAction.Text(keyWithId.label)) + } else { + keyWithId + } } val labelToIdMap = keysWithEnsuredIds @@ -535,8 +808,31 @@ class KeyboardEditorViewModel @Inject constructor( if (newKeyId != null) newKeyId to map else null }.toMap() - val finalLayout = templateLayout.copy( - keys = keysWithEnsuredIds, + val finalLayoutBase = if (templateLayout.usesFlexiblePlacement()) { + val keysByOriginalId = templateLayout.keys + .zip(keysWithEnsuredIds) + .mapNotNull { (original, updated) -> + original.keyId?.let { it to updated } + } + .toMap() + val updatedItems = templateLayout.items.map { item -> + when (item) { + is SpacerItem -> item + is KeyItem -> { + val updatedKeyData = keysByOriginalId[item.keyData.keyId] ?: item.keyData + item.copy( + id = updatedKeyData.keyId ?: item.id, + keyData = updatedKeyData + ) + } + } + } + templateLayout.copyWithItems(updatedItems) + } else { + templateLayout.copyWithKeys(keysWithEnsuredIds) + } + + val finalLayout = finalLayoutBase.copy( flickKeyMaps = reKeyedFlickMaps, circularFlickKeyMaps = reKeyedCircularFlickMaps, twoStepFlickKeyMaps = reKeyedTwoStepMaps, @@ -556,9 +852,15 @@ class KeyboardEditorViewModel @Inject constructor( return repository.getAllFullLayoutsForExport() } - fun importLayouts(layouts: List) { - viewModelScope.launch { - repository.importLayouts(layouts) - } + /** + * Import 用 entrypoint。 + * + * 受け取るのは外部 JSON から正規化済みの [ImportableKeyboardLayout] であり、 + * Room の Relation 用 [FullKeyboardLayout] ではない。 + * これにより spacers などの新フィールドが将来欠損していても、 + * Repository / DAO 層には null が伝搬しない設計になっている。 + */ + suspend fun importLayouts(layouts: List): KeyboardLayoutImportResult { + return repository.importLayouts(layouts) } } 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 adbddffa8..276d68bf8 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 @@ -23,19 +23,17 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.reflect.TypeToken -import com.google.gson.stream.JsonReader import com.kazumaproject.markdownhelperkeyboard.R -import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutBackupImporter +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 import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.ui.adapter.KeyboardLayoutAdapter import com.kazumaproject.markdownhelperkeyboard.databinding.FragmentKeyboardListBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber import java.io.OutputStreamWriter -import java.io.StringReader @AndroidEntryPoint class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { @@ -202,20 +200,14 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { private fun launchImportPicker() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) - type = "application/json" + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/json", "text/xml", "application/xml", "text/plain")) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } importLauncher.launch(intent) } - private val exportGson: Gson by lazy { - GsonBuilder() - .disableHtmlEscaping() - .serializeNulls() - .create() - } - private fun exportLayouts(uri: Uri) { viewLifecycleOwner.lifecycleScope.launch { try { @@ -229,7 +221,9 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { return@launch } - val jsonString = exportGson.toJson(layoutsToExport) + // FullKeyboardLayout(Room モデル) を直接 Gson に渡さない。 + // exporter 側で schemaVersion 付き object 形式の JSON にする。 + val jsonString = KeyboardLayoutJsonExporter.toJson(layoutsToExport) requireContext().contentResolver.openOutputStream(uri, "w")?.use { os -> OutputStreamWriter(os, Charsets.UTF_8).use { writer -> @@ -262,33 +256,10 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { return@launch } - val jsonString = bytes.toString(Charsets.UTF_8) - .trimStart('\uFEFF') - .replace("\u0000", "") - - val type = object : TypeToken>() {}.type - - val gson = GsonBuilder() - .setLenient() - .create() - - val reader = JsonReader(StringReader(jsonString)).apply { - isLenient = true - } - - val layouts: List = gson.fromJson(reader, type) ?: emptyList() - - if (layouts.isEmpty()) { - Toast.makeText(context, "インポート対象が空です", Toast.LENGTH_LONG).show() - return@launch - } - - keyboardEditorViewMode.importLayouts(layouts) - Toast.makeText( - context, - "${layouts.size}件のレイアウトをインポートしました", - Toast.LENGTH_SHORT - ).show() + val rawText = bytes.toString(Charsets.UTF_8) + val parseResult = KeyboardLayoutBackupImporter.importText(rawText) + val finalResult = saveParsedLayouts(parseResult) + showImportResult(finalResult) } catch (e: Exception) { Toast.makeText( @@ -301,6 +272,90 @@ class KeyboardListFragment : Fragment(R.layout.fragment_keyboard_list) { } } + private suspend fun saveParsedLayouts( + parseResult: KeyboardLayoutImportResult + ): KeyboardLayoutImportResult { + return when (parseResult) { + is KeyboardLayoutImportResult.Failure -> parseResult + is KeyboardLayoutImportResult.Success -> { + val storageResult = keyboardEditorViewMode.importLayouts(parseResult.layouts) + mergeImportResults(parseResult, storageResult) + } + + is KeyboardLayoutImportResult.PartialSuccess -> { + val storageResult = keyboardEditorViewMode.importLayouts(parseResult.layouts) + mergeImportResults(parseResult, storageResult) + } + } + } + + private fun mergeImportResults( + parseResult: KeyboardLayoutImportResult, + storageResult: KeyboardLayoutImportResult + ): KeyboardLayoutImportResult { + val parseErrors = (parseResult as? KeyboardLayoutImportResult.PartialSuccess)?.errors.orEmpty() + val parseWarnings = when (parseResult) { + is KeyboardLayoutImportResult.Success -> parseResult.warnings + is KeyboardLayoutImportResult.PartialSuccess -> parseResult.warnings + is KeyboardLayoutImportResult.Failure -> emptyList() + } + return when (storageResult) { + is KeyboardLayoutImportResult.Success -> { + if (parseErrors.isEmpty()) { + storageResult.copy(warnings = parseWarnings + storageResult.warnings) + } else { + KeyboardLayoutImportResult.PartialSuccess( + layouts = storageResult.layouts, + errors = parseErrors, + warnings = parseWarnings + storageResult.warnings + ) + } + } + + is KeyboardLayoutImportResult.PartialSuccess -> storageResult.copy( + errors = parseErrors + storageResult.errors, + warnings = parseWarnings + storageResult.warnings + ) + + is KeyboardLayoutImportResult.Failure -> storageResult + } + } + + private fun showImportResult(result: KeyboardLayoutImportResult) { + val message = when (result) { + is KeyboardLayoutImportResult.Success -> + "${result.layouts.size}件のレイアウトをインポートしました" + + is KeyboardLayoutImportResult.PartialSuccess -> + "${result.layouts.size}件をインポートしました。一部のレイアウトは読み込めませんでした" + + is KeyboardLayoutImportResult.Failure -> importFailureMessage(result.error) + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + + private fun importFailureMessage(error: KeyboardLayoutImportError): String { + return when (error) { + KeyboardLayoutImportError.EmptyInput -> "ファイルが空です" + KeyboardLayoutImportError.UnsupportedFormat -> "対応していないバックアップ形式です" + is KeyboardLayoutImportError.MalformedJson -> "JSON の読み込みに失敗しました" + is KeyboardLayoutImportError.InvalidJson -> "JSON の読み込みに失敗しました" + is KeyboardLayoutImportError.InvalidXml -> "XML の読み込みに失敗しました" + KeyboardLayoutImportError.NoLayoutPayloadFound -> + "バックアップ内にカスタムキーボードレイアウトが見つかりませんでした" + + KeyboardLayoutImportError.NoImportableLayouts -> "インポート可能なレイアウトがありません" + KeyboardLayoutImportError.SchemaMismatch -> "バックアップ形式が想定と異なります" + is KeyboardLayoutImportError.MissingLayout -> "インポート可能なレイアウトがありません" + is KeyboardLayoutImportError.MissingKeys -> "インポート可能なキーがないレイアウトがありました" + is KeyboardLayoutImportError.InvalidLayoutSize -> "レイアウトのサイズが不正です" + is KeyboardLayoutImportError.InvalidKeyPlacement -> "キーの配置が不正です" + is KeyboardLayoutImportError.BrokenOwnerReference -> "バックアップ内の参照関係が壊れています" + is KeyboardLayoutImportError.ValidationFailed -> "インポート可能なレイアウトがありません" + is KeyboardLayoutImportError.StorageFailed -> "保存に失敗しました" + } + } + private fun showDeleteConfirmationDialog(layoutId: Long) { MaterialAlertDialogBuilder(requireContext()) .setTitle(getString(com.kazumaproject.core.R.string.dialog_delete_title)) 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 f5cce7bcd..138ecfa1b 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt @@ -15,6 +15,7 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CircularFli import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMapping import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.LongPressFlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.SpacerDefinition import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.TfbiFlickDirectionConverter import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.TwoStepFlickMapping import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.TwoStepLongPressMappingEntity @@ -67,8 +68,9 @@ import com.kazumaproject.markdownhelperkeyboard.user_template.database.UserTempl GemmaPromptTemplate::class, DeleteKeyFlickDeleteTarget::class, PhysicalKeyboardShortcutItem::class, + SpacerDefinition::class, ], - version = 28, + version = 30, exportSchema = false ) @TypeConverters( @@ -708,5 +710,48 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `key_definitions` ADD COLUMN `rowUnits` INTEGER") + db.execSQL("ALTER TABLE `key_definitions` ADD COLUMN `columnUnits` INTEGER") + db.execSQL("ALTER TABLE `key_definitions` ADD COLUMN `rowSpanUnits` INTEGER") + db.execSQL("ALTER TABLE `key_definitions` ADD COLUMN `columnSpanUnits` INTEGER") + } + } + + /** + * バージョン29から30へのマイグレーション。 + * KeyboardLayout に行内 Spacer (SpacerItem) を永続化するための + * `spacer_definitions` テーブルを追加します。 + * + * これにより QWERTY / AZERTY / Dvorak / Colemak テンプレートの + * 「Shift | spacer | 文字キー | spacer | Delete」のような + * 行途中の Spacer 配置も DB ↔ アプリ間でラウンドトリップ可能になります。 + */ + val MIGRATION_29_30 = object : Migration(29, 30) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `spacer_definitions` ( + `spacerId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `ownerLayoutId` INTEGER NOT NULL, + `itemIdentifier` TEXT NOT NULL, + `rowUnits` INTEGER NOT NULL, + `columnUnits` INTEGER NOT NULL, + `rowSpanUnits` INTEGER NOT NULL, + `columnSpanUnits` INTEGER NOT NULL, + `sortOrder` INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(`ownerLayoutId`) REFERENCES `keyboard_layouts`(`layoutId`) ON DELETE CASCADE ON UPDATE NO ACTION + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_spacer_definitions_ownerLayoutId` + ON `spacer_definitions`(`ownerLayoutId`) + """.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 cb654e44b..5efc9108f 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 @@ -37,6 +37,8 @@ import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.M import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_25_26 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_26_27 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_2_3 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_3_4 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_4_5 @@ -116,6 +118,8 @@ object AppModule { MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, + MIGRATION_28_29, + MIGRATION_29_30, ) .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 1beb754ed..044d4b29a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt @@ -3,12 +3,20 @@ package com.kazumaproject.markdownhelperkeyboard.repository import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyActionMapper import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.KeyboardLayoutItem +import com.kazumaproject.custom_keyboard.data.SpacerItem +import com.kazumaproject.custom_keyboard.data.copyWithKeys +import com.kazumaproject.custom_keyboard.data.copyWithItems +import com.kazumaproject.custom_keyboard.data.toKeyItem import com.kazumaproject.custom_keyboard.data.toCircularFlickDirection +import com.kazumaproject.custom_keyboard.data.usesFlexiblePlacement import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CircularFlickMapping import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout @@ -16,11 +24,15 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMappin import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition 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.data.toDbStrings import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.toFlickAction import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database.KeyboardLayoutDao +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.ImportableKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportError +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber @@ -34,7 +46,8 @@ private data class DbKeyboardLayoutParts( val circularFlicksMap: Map>, val twoStepMap: Map>, val longPressFlicksMap: Map>, - val twoStepLongPressMap: Map> + val twoStepLongPressMap: Map>, + val spacers: List = emptyList() ) fun ensureStableIdsForLayouts( @@ -68,69 +81,129 @@ class KeyboardRepository @Inject constructor( * - 名前衝突回避 * - createdAt は import 時刻 * - sortOrder は「最上位に積む」(max+1) を順に付与 + * + * 引数は [ImportableKeyboardLayout] (= 既に importer 側で正規化済みで、 + * 全ての List が non-null になっているモデル)。 + * 外部 JSON DTO ([com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutExportDto]) + * は決してここに渡さない。 */ - suspend fun importLayouts(layouts: List) { + suspend fun importLayouts(layouts: List): KeyboardLayoutImportResult { + if (layouts.isEmpty()) { + return KeyboardLayoutImportResult.Failure(KeyboardLayoutImportError.NoImportableLayouts) + } + // まとめて import するときに max を毎回 DB に聞かない var currentMaxOrder = dao.getMaxSortOrder() + val importedLayouts = mutableListOf() + val errors = mutableListOf() + + layouts.forEachIndexed { layoutIndex, importable -> + try { + var newName = importable.layout.name + var nameExists = dao.findLayoutByName(newName) != null + var counter = 1 + while (nameExists) { + newName = "${importable.layout.name} (${counter})" + nameExists = dao.findLayoutByName(newName) != null + counter++ + } - for (fullLayout in layouts) { - var newName = fullLayout.layout.name - var nameExists = dao.findLayoutByName(newName) != null - var counter = 1 - while (nameExists) { - newName = "${fullLayout.layout.name} (${counter})" - nameExists = dao.findLayoutByName(newName) != null - counter++ - } + currentMaxOrder += 1 + val importedStableId = importable.layout.stableId + val stableIdToInsert = if (importedStableId.isNullOrBlank() || + dao.findLayoutByStableId(importedStableId) != null + ) { + UUID.randomUUID().toString() + } else { + importedStableId + } - currentMaxOrder += 1 - val importedStableId = fullLayout.layout.stableId - val stableIdToInsert = if (importedStableId.isNullOrBlank() || - dao.findLayoutByStableId(importedStableId) != null - ) { - UUID.randomUUID().toString() - } else { - importedStableId - } + val layoutToInsert = importable.layout.copy( + layoutId = 0, + name = newName, + createdAt = System.currentTimeMillis(), + sortOrder = currentMaxOrder, + stableId = stableIdToInsert + ) - val layoutToInsert = fullLayout.layout.copy( - layoutId = 0, - name = newName, - createdAt = System.currentTimeMillis(), - sortOrder = currentMaxOrder, - stableId = stableIdToInsert - ) + // Imported numeric ids are never trusted. The DAO inserts with + // auto-generated ids and then reattaches child rows by stable + // keyIdentifier, so legacy keyId/ownerLayoutId collisions cannot + // replace existing rows. + val normalizedKeysWithFlicks = importable.keysWithFlicks.map { keyWithFlicks -> + keyWithFlicks.copy( + key = keyWithFlicks.key.copy(keyId = 0, ownerLayoutId = 0), + flicks = keyWithFlicks.flicks.map { it.copy(ownerKeyId = 0) }, + circularFlicks = keyWithFlicks.circularFlicks.map { it.copy(ownerKeyId = 0) }, + twoStepFlicks = keyWithFlicks.twoStepFlicks.map { it.copy(ownerKeyId = 0) }, + longPressFlicks = keyWithFlicks.longPressFlicks.map { it.copy(ownerKeyId = 0) }, + twoStepLongPressFlicks = keyWithFlicks.twoStepLongPressFlicks.map { + it.copy(ownerKeyId = 0) + } + ) + } - val keysToInsert = fullLayout.keysWithFlicks.map { it.key } + val keysToInsert = normalizedKeysWithFlicks.map { it.key } - val flicksMap = fullLayout.keysWithFlicks.associate { keyWithFlicks -> - keyWithFlicks.key.keyIdentifier to keyWithFlicks.flicks - } + val flicksMap = normalizedKeysWithFlicks.associate { keyWithFlicks -> + keyWithFlicks.key.keyIdentifier to keyWithFlicks.flicks + } - val circularFlicksMap = fullLayout.keysWithFlicks.associate { keyWithFlicks -> - keyWithFlicks.key.keyIdentifier to keyWithFlicks.circularFlicks - } + val circularFlicksMap = normalizedKeysWithFlicks.associate { keyWithFlicks -> + keyWithFlicks.key.keyIdentifier to keyWithFlicks.circularFlicks + } - val twoStepMap = fullLayout.keysWithFlicks.associate { keyWithFlicks -> - keyWithFlicks.key.keyIdentifier to keyWithFlicks.twoStepFlicks - } + val twoStepMap = normalizedKeysWithFlicks.associate { keyWithFlicks -> + keyWithFlicks.key.keyIdentifier to keyWithFlicks.twoStepFlicks + } - val longPressFlicksMap = fullLayout.keysWithFlicks.associate { keyWithFlicks -> - keyWithFlicks.key.keyIdentifier to keyWithFlicks.longPressFlicks - } + val longPressFlicksMap = normalizedKeysWithFlicks.associate { keyWithFlicks -> + keyWithFlicks.key.keyIdentifier to keyWithFlicks.longPressFlicks + } + + val twoStepLongPressMap = normalizedKeysWithFlicks.associate { keyWithFlicks -> + keyWithFlicks.key.keyIdentifier to keyWithFlicks.twoStepLongPressFlicks + } - val twoStepLongPressMap = fullLayout.keysWithFlicks.associate { keyWithFlicks -> - keyWithFlicks.key.keyIdentifier to keyWithFlicks.twoStepLongPressFlicks + // SpacerItem 復元: 元レイアウトの spacers を新規 layoutId 用に複製 + val spacersToInsert = importable.spacers.map { spacer -> + spacer.copy(spacerId = 0, ownerLayoutId = 0) + } + + dao.insertFullKeyboardLayout( + layoutToInsert, + keysToInsert, + flicksMap, + circularFlicksMap, + twoStepMap, + longPressFlicksMap, + twoStepLongPressMap, + spacersToInsert + ) + importedLayouts += importable.copy( + layout = layoutToInsert, + keysWithFlicks = normalizedKeysWithFlicks, + spacers = spacersToInsert + ) + } catch (e: Exception) { + Timber.e(e, "importLayouts storage failed index=%s exception=%s", layoutIndex, e::class.java.simpleName) + errors += KeyboardLayoutImportError.StorageFailed( + layoutIndex = layoutIndex, + exceptionClass = e::class.java.simpleName, + message = e.message + ) } + } + + return when { + importedLayouts.isNotEmpty() && errors.isEmpty() -> + KeyboardLayoutImportResult.Success(importedLayouts) - dao.insertFullKeyboardLayout( - layoutToInsert, - keysToInsert, - flicksMap, - circularFlicksMap, - twoStepMap, - longPressFlicksMap, - twoStepLongPressMap + importedLayouts.isNotEmpty() -> + KeyboardLayoutImportResult.PartialSuccess(importedLayouts, errors) + + else -> KeyboardLayoutImportResult.Failure( + errors.firstOrNull() ?: KeyboardLayoutImportError.StorageFailed() ) } } @@ -253,7 +326,8 @@ class KeyboardRepository @Inject constructor( parts.circularFlicksMap, parts.twoStepMap, parts.longPressFlicksMap, - parts.twoStepLongPressMap + parts.twoStepLongPressMap, + parts.spacers ) } @@ -325,6 +399,11 @@ class KeyboardRepository @Inject constructor( keyWithFlicks.key.keyIdentifier to newTwoStepLongPress } + // 元レイアウトの SpacerItem も複製 + val newSpacers = originalLayout.spacers.map { spacer -> + spacer.copy(spacerId = 0, ownerLayoutId = 0) + } + dao.insertFullKeyboardLayout( newLayoutInfo, newKeys, @@ -332,7 +411,8 @@ class KeyboardRepository @Inject constructor( newCircularFlicksMap, newTwoStepMap, newLongPressFlicksMap, - newTwoStepLongPressMap + newTwoStepLongPressMap, + newSpacers ) } @@ -390,8 +470,25 @@ class KeyboardRepository @Inject constructor( } } - return dbLayout.copy( - keys = newKeys, + val convertedLayout = if (dbLayout.usesFlexiblePlacement()) { + val newKeysById = newKeys.mapNotNull { key -> + key.keyId?.let { it to key } + }.toMap() + val newItems = dbLayout.items.map { item -> + when (item) { + is SpacerItem -> item + is KeyItem -> { + val updatedKey = newKeysById[item.keyData.keyId] ?: item.keyData + item.copy(keyData = updatedKey) + } + } + } + dbLayout.copyWithItems(newItems) + } else { + dbLayout.copyWithKeys(newKeys) + } + + return convertedLayout.copy( flickKeyMaps = newFlickKeyMaps, twoStepFlickKeyMaps = newTwoStepFlickKeyMaps, longPressFlickKeyMaps = newLongPressFlickKeyMaps, @@ -474,16 +571,23 @@ class KeyboardRepository @Inject constructor( identifier to firstMap }.toMap() + val keyItems = mutableListOf() + val keys: List = dbLayout.keysWithFlicks.map { keyWithFlicks -> val dbKey = keyWithFlicks.key - val actionObject: KeyAction? = if (dbKey.isSpecialKey) { - KeyActionMapper.toKeyAction(dbKey.action) + val actionObject: KeyAction? = KeyActionMapper.toKeyAction(dbKey.action) + val restoredAction = actionObject ?: if ( + !dbKey.isSpecialKey && + dbKey.keyType == KeyType.NORMAL && + dbKey.label.isNotBlank() + ) { + KeyAction.Text(dbKey.label) } else { null } - if (actionObject == null) { + val keyData = if (restoredAction == null) { KeyData( label = dbKey.label, row = dbKey.row, @@ -506,46 +610,65 @@ class KeyboardRepository @Inject constructor( keyType = dbKey.keyType, rowSpan = dbKey.rowSpan, colSpan = dbKey.colSpan, - isSpecialKey = true, - drawableResId = when (actionObject) { - KeyAction.Backspace -> com.kazumaproject.core.R.drawable.backspace_24px - KeyAction.ChangeInputMode -> com.kazumaproject.core.R.drawable.backspace_24px - KeyAction.Convert -> com.kazumaproject.core.R.drawable.henkan - KeyAction.Copy -> com.kazumaproject.core.R.drawable.content_copy_24dp - KeyAction.Delete -> com.kazumaproject.core.R.drawable.backspace_24px - KeyAction.Enter -> com.kazumaproject.core.R.drawable.baseline_keyboard_return_24 - KeyAction.ForceNewLine -> com.kazumaproject.core.R.drawable.baseline_keyboard_return_24 - KeyAction.MoveCursorLeft -> com.kazumaproject.core.R.drawable.baseline_arrow_left_24 - KeyAction.MoveCursorRight -> com.kazumaproject.core.R.drawable.baseline_arrow_right_24 - KeyAction.MoveCustomKeyboardTab -> com.kazumaproject.core.R.drawable.keyboard_command_key_24px - is KeyAction.MoveToCustomKeyboard -> com.kazumaproject.core.R.drawable.keyboard_24px - KeyAction.Paste -> com.kazumaproject.core.R.drawable.content_paste_24px - KeyAction.SelectAll -> com.kazumaproject.core.R.drawable.text_select_start_24dp - KeyAction.SelectLeft -> com.kazumaproject.core.R.drawable.baseline_arrow_left_24 - KeyAction.SelectRight -> com.kazumaproject.core.R.drawable.baseline_arrow_right_24 - KeyAction.ShiftKey -> com.kazumaproject.core.R.drawable.shift_24px - KeyAction.CapLockKey -> com.kazumaproject.core.R.drawable.caps_lock_outline - KeyAction.SwitchRomajiEnglish -> com.kazumaproject.core.R.drawable.language_japanese_kana_right_bold_24px - KeyAction.ShowEmojiKeyboard -> com.kazumaproject.core.R.drawable.baseline_emoji_emotions_24 - KeyAction.Space -> com.kazumaproject.core.R.drawable.baseline_space_bar_24 - KeyAction.SwitchToEnglishLayout -> com.kazumaproject.core.R.drawable.input_mode_english_custom - KeyAction.SwitchToKanaLayout -> com.kazumaproject.core.R.drawable.input_mode_japanese_select_custom - KeyAction.SwitchToNextIme -> com.kazumaproject.core.R.drawable.language_24dp - KeyAction.SwitchToNumberLayout -> com.kazumaproject.core.R.drawable.input_mode_number_select_custom - KeyAction.ToggleCase -> com.kazumaproject.core.R.drawable.english_small - KeyAction.ToggleDakuten -> com.kazumaproject.core.R.drawable.kana_small_custom - KeyAction.ToggleKatakana -> com.kazumaproject.core.R.drawable.katakana - KeyAction.VoiceInput -> com.kazumaproject.core.R.drawable.settings_voice_24px - KeyAction.DeleteUntilSymbol -> com.kazumaproject.core.R.drawable.backspace_24px_until_symbol - KeyAction.DeleteAfterCursorUntilSymbol -> com.kazumaproject.core.R.drawable.backspace_24px_after_cursor - KeyAction.SwitchDirectMode -> com.kazumaproject.core.R.drawable.language_japanese_kana_right_24px - else -> null - }, + isSpecialKey = dbKey.isSpecialKey, + drawableResId = if (dbKey.isSpecialKey) drawableResIdForAction(restoredAction) else null, keyId = dbKey.keyIdentifier, - action = actionObject + action = restoredAction ) } + val placement = GridPlacement( + rowUnits = dbKey.rowUnits ?: dbKey.row * 2, + columnUnits = dbKey.columnUnits ?: dbKey.column * 2, + rowSpanUnits = dbKey.rowSpanUnits ?: dbKey.rowSpan * 2, + columnSpanUnits = dbKey.columnSpanUnits ?: dbKey.colSpan * 2 + ) + keyItems += KeyItem( + id = keyData.keyId ?: dbKey.keyIdentifier, + keyData = keyData, + placement = placement + ) + keyData } + // 永続化された SpacerItem を復元 (行内 Spacer 含む完全復元) + // 旧データに spacer_definitions が無い場合は restoreLeadingSpacers() に + // フォールバックして「行頭 Spacer」だけは推測復元する。 + val storedSpacers: List = dbLayout.spacers + .sortedBy { it.sortOrder } + .map { spacer -> + SpacerItem( + id = spacer.itemIdentifier.ifBlank { + "spacer_${spacer.spacerId}" + }, + placement = GridPlacement( + rowUnits = spacer.rowUnits, + columnUnits = spacer.columnUnits, + rowSpanUnits = spacer.rowSpanUnits, + columnSpanUnits = spacer.columnSpanUnits + ) + ) + } + + val items: List = if (storedSpacers.isNotEmpty()) { + // 完全復元: items 順は (Spacer, Key) を rowUnits → columnUnits でマージ + (storedSpacers + keyItems).sortedWith( + compareBy({ it.placement.rowUnits }, { it.placement.columnUnits }) + ) + } else { + // 旧データ互換: spacer_definitions が無いレイアウトは行頭 Spacer のみ推測 + restoreLeadingSpacers(keyItems) + keyItems + } + + // columnUnitCount / rowUnitCount は KeyDefinition.rowUnits 等の有無に応じて + // 厳密値 / フォールバック値を決定する。 + val derivedColumnUnitCount = items + .maxOfOrNull { it.placement.columnUnits + it.placement.columnSpanUnits } + ?: (dbLayout.layout.columnCount * 2) + val derivedRowUnitCount = items + .maxOfOrNull { it.placement.rowUnits + it.placement.rowSpanUnits } + ?: (dbLayout.layout.rowCount * 2) + + val columnUnitCount = maxOf(derivedColumnUnitCount, dbLayout.layout.columnCount * 2) + val rowUnitCount = maxOf(derivedRowUnitCount, dbLayout.layout.rowCount * 2) return KeyboardLayout( keys = keys, @@ -565,10 +688,69 @@ class KeyboardRepository @Inject constructor( }, twoStepFlickKeyMaps = twoStepMaps, longPressFlickKeyMaps = longPressFlickMaps, - twoStepLongPressKeyMaps = twoStepLongPressMaps + twoStepLongPressKeyMaps = twoStepLongPressMaps, + items = items, + columnUnitCount = columnUnitCount, + rowUnitCount = rowUnitCount ) } + private fun drawableResIdForAction(action: KeyAction): Int? { + return when (action) { + KeyAction.Backspace -> com.kazumaproject.core.R.drawable.backspace_24px + KeyAction.ChangeInputMode -> com.kazumaproject.core.R.drawable.backspace_24px + KeyAction.Convert -> com.kazumaproject.core.R.drawable.henkan + KeyAction.Copy -> com.kazumaproject.core.R.drawable.content_copy_24dp + KeyAction.Delete -> com.kazumaproject.core.R.drawable.backspace_24px + KeyAction.Enter -> com.kazumaproject.core.R.drawable.baseline_keyboard_return_24 + KeyAction.ForceNewLine -> com.kazumaproject.core.R.drawable.baseline_keyboard_return_24 + KeyAction.MoveCursorLeft -> com.kazumaproject.core.R.drawable.baseline_arrow_left_24 + KeyAction.MoveCursorRight -> com.kazumaproject.core.R.drawable.baseline_arrow_right_24 + KeyAction.MoveCustomKeyboardTab -> com.kazumaproject.core.R.drawable.keyboard_command_key_24px + is KeyAction.MoveToCustomKeyboard -> com.kazumaproject.core.R.drawable.keyboard_24px + KeyAction.Paste -> com.kazumaproject.core.R.drawable.content_paste_24px + KeyAction.SelectAll -> com.kazumaproject.core.R.drawable.text_select_start_24dp + KeyAction.SelectLeft -> com.kazumaproject.core.R.drawable.baseline_arrow_left_24 + KeyAction.SelectRight -> com.kazumaproject.core.R.drawable.baseline_arrow_right_24 + KeyAction.ShiftKey -> com.kazumaproject.core.R.drawable.shift_24px + KeyAction.CapLockKey -> com.kazumaproject.core.R.drawable.caps_lock_outline + KeyAction.SwitchRomajiEnglish -> com.kazumaproject.core.R.drawable.language_japanese_kana_right_bold_24px + KeyAction.ShowEmojiKeyboard -> com.kazumaproject.core.R.drawable.baseline_emoji_emotions_24 + KeyAction.Space -> com.kazumaproject.core.R.drawable.baseline_space_bar_24 + KeyAction.SwitchToEnglishLayout -> com.kazumaproject.core.R.drawable.input_mode_english_custom + KeyAction.SwitchToKanaLayout -> com.kazumaproject.core.R.drawable.input_mode_japanese_select_custom + KeyAction.SwitchToNextIme -> com.kazumaproject.core.R.drawable.language_24dp + KeyAction.SwitchToNumberLayout -> com.kazumaproject.core.R.drawable.input_mode_number_select_custom + KeyAction.ToggleCase -> com.kazumaproject.core.R.drawable.english_small + KeyAction.ToggleDakuten -> com.kazumaproject.core.R.drawable.kana_small_custom + KeyAction.ToggleKatakana -> com.kazumaproject.core.R.drawable.katakana + KeyAction.VoiceInput -> com.kazumaproject.core.R.drawable.settings_voice_24px + KeyAction.DeleteUntilSymbol -> com.kazumaproject.core.R.drawable.backspace_24px_until_symbol + KeyAction.DeleteAfterCursorUntilSymbol -> com.kazumaproject.core.R.drawable.backspace_24px_after_cursor + KeyAction.SwitchDirectMode -> com.kazumaproject.core.R.drawable.language_japanese_kana_right_24px + else -> null + } + } + + private fun restoreLeadingSpacers(keyItems: List): List { + return keyItems + .groupBy { it.placement.rowUnits } + .mapNotNull { (rowUnits, rowItems) -> + val minColumnUnits = rowItems.minOfOrNull { it.placement.columnUnits } ?: return@mapNotNull null + if (minColumnUnits <= 0) return@mapNotNull null + val rowSpanUnits = rowItems.minOfOrNull { it.placement.rowSpanUnits } ?: 2 + SpacerItem( + id = "restored_row_${rowUnits}_start_spacer", + placement = GridPlacement( + rowUnits = rowUnits, + columnUnits = 0, + rowSpanUnits = rowSpanUnits, + columnSpanUnits = minColumnUnits + ) + ) + } + } + private fun convertToDbModel( uiLayout: KeyboardLayout ): DbKeyboardLayoutParts { @@ -579,15 +761,21 @@ class KeyboardRepository @Inject constructor( val twoStepMap = mutableMapOf>() val longPressFlicksMap = mutableMapOf>() val twoStepLongPressMap = mutableMapOf>() + val itemByKeyId = uiLayout.items + .filterIsInstance() + .flatMap { item -> + listOfNotNull( + item.id to item, + item.keyData.keyId?.let { it to item } + ) + } + .toMap() uiLayout.keys.forEach { keyData -> val keyIdentifier = keyData.keyId ?: UUID.randomUUID().toString() - val actionString: String? = if (keyData.isSpecialKey) { - KeyActionMapper.fromKeyAction(keyData.action) - } else { - null - } + val actionString: String? = KeyActionMapper.fromKeyAction(keyData.action) + val placement = itemByKeyId[keyIdentifier]?.placement ?: keyData.toKeyItem().placement keys.add( KeyDefinition( @@ -602,7 +790,11 @@ class KeyboardRepository @Inject constructor( isSpecialKey = keyData.isSpecialKey, drawableResId = null, keyIdentifier = keyIdentifier, - action = actionString + action = actionString, + rowUnits = placement.rowUnits, + columnUnits = placement.columnUnits, + rowSpanUnits = placement.rowSpanUnits, + columnSpanUnits = placement.columnSpanUnits ) ) @@ -675,6 +867,22 @@ class KeyboardRepository @Inject constructor( } } + // SpacerItem を SpacerDefinition に変換 (順序情報は items の登場順を保持) + val spacerDefinitions = uiLayout.items + .filterIsInstance() + .mapIndexed { index, spacer -> + SpacerDefinition( + spacerId = 0, + ownerLayoutId = 0, + itemIdentifier = spacer.id, + rowUnits = spacer.placement.rowUnits, + columnUnits = spacer.placement.columnUnits, + rowSpanUnits = spacer.placement.rowSpanUnits, + columnSpanUnits = spacer.placement.columnSpanUnits, + sortOrder = index + ) + } + // Map> -> Map> にして返す return DbKeyboardLayoutParts( keys = keys, @@ -682,7 +890,8 @@ class KeyboardRepository @Inject constructor( circularFlicksMap = circularFlicksMap.mapValues { it.value.toList() }, twoStepMap = twoStepMap.mapValues { it.value.toList() }, longPressFlicksMap = longPressFlicksMap.mapValues { it.value.toList() }, - twoStepLongPressMap = twoStepLongPressMap.mapValues { it.value.toList() } + twoStepLongPressMap = twoStepLongPressMap.mapValues { it.value.toList() }, + spacers = spacerDefinitions ) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt new file mode 100644 index 000000000..49a8fc43d --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt @@ -0,0 +1,678 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export + +import com.google.gson.JsonParser +import com.google.gson.annotations.SerializedName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [KeyboardLayoutJsonImporter] / [KeyboardLayoutJsonExporter] の互換性テスト。 + * + * 主な観点: + * - 旧 root array 形式 (spacers なし / null / 完全形) + * - flick 系 List 欠損 + * - 新 schemaVersion = 1 object 形式 + * - export round-trip + */ +class KeyboardLayoutJsonImporterTest { + + /** + * 共通: テストで使う最小レイアウト JSON。 + * 古いバージョン由来の JSON にあわせて spacers / 各 flick 系 list は意図的に省略する。 + */ + private val minimalLayoutJson = """ + { + "layout": { + "layoutId": 0, + "name": "TestKeyboard", + "columnCount": 5, + "rowCount": 4, + "isRomaji": false, + "isDirectMode": false, + "createdAt": 0, + "sortOrder": 0, + "stableId": "stable-1" + }, + "keysWithFlicks": [] + } + """.trimIndent() + + // ----------------------------- + // A. 旧 root array で spacers が無くても import できる + // ----------------------------- + @Test + fun parse_legacyRootArray_withoutSpacers_doesNotCrash_andSpacersIsEmpty() { + val json = "[$minimalLayoutJson]" + + val result = parseSuccessLayouts(json) + + assertEquals(1, result.size) + val layout = result.single() + assertEquals("TestKeyboard", layout.layout.name) + assertEquals(emptyList(), layout.spacers) + assertEquals(emptyList(), layout.keysWithFlicks) + } + + // ----------------------------- + // B. spacers: null でも import できる + // ----------------------------- + @Test + fun parse_legacyRootArray_withNullSpacers_isNormalizedToEmpty() { + val json = """ + [ + { + "layout": { + "layoutId": 0, + "name": "TestKeyboard", + "columnCount": 5, + "rowCount": 4, + "isRomaji": false, + "isDirectMode": false, + "createdAt": 0, + "sortOrder": 0, + "stableId": "stable-1" + }, + "keysWithFlicks": [], + "spacers": null + } + ] + """.trimIndent() + + val result = parseSuccessLayouts(json) + + assertEquals(1, result.size) + assertEquals(emptyList(), result.single().spacers) + } + + // ----------------------------- + // C. flick 系 List が無くても / null でも import できる + // ----------------------------- + @Test + fun parse_keysWithFlicks_missingFlickFields_isNormalizedToEmpty() { + val json = """ + [ + { + "layout": { + "layoutId": 0, + "name": "TestKeyboard", + "columnCount": 5, + "rowCount": 4, + "isRomaji": false, + "isDirectMode": false, + "createdAt": 0, + "sortOrder": 0, + "stableId": "stable-1" + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 0, + "ownerLayoutId": 0, + "label": "あ", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "PETAL_FLICK", + "isSpecialKey": false, + "drawableResId": null, + "keyIdentifier": "key-1", + "action": null + } + }, + { + "key": { + "keyId": 0, + "ownerLayoutId": 0, + "label": "い", + "row": 0, + "column": 1, + "rowSpan": 1, + "colSpan": 1, + "keyType": "PETAL_FLICK", + "isSpecialKey": false, + "drawableResId": null, + "keyIdentifier": "key-2", + "action": null + }, + "flicks": null, + "circularFlicks": null, + "twoStepFlicks": null, + "longPressFlicks": null, + "twoStepLongPressFlicks": null + } + ] + } + ] + """.trimIndent() + + val result = parseSuccessLayouts(json) + + assertEquals(1, result.size) + val layout = result.single() + assertEquals(2, layout.keysWithFlicks.size) + layout.keysWithFlicks.forEach { kw -> + assertEquals(emptyList(), kw.flicks) + assertEquals(emptyList(), kw.circularFlicks) + assertEquals(emptyList(), kw.twoStepFlicks) + assertEquals(emptyList(), kw.longPressFlicks) + assertEquals(emptyList(), kw.twoStepLongPressFlicks) + } + } + + // ----------------------------- + // D. 新 schemaVersion = 1 object 形式を import できる + // ----------------------------- + @Test + fun parse_schemaVersion1_objectFormat_isAccepted() { + val json = """ + { + "schemaVersion": 1, + "layouts": [ + $minimalLayoutJson + ] + } + """.trimIndent() + + val result = parseSuccessLayouts(json) + + assertEquals(1, result.size) + assertEquals("TestKeyboard", result.single().layout.name) + } + + // ----------------------------- + // E. export は schemaVersion = 1 の object 形式になる + // ----------------------------- + @Test + fun exporter_emitsSchemaVersionedObjectRoot() { + // FullKeyboardLayout を経由しない簡易テスト: + // export のフォーマット保証だけ確認するため、 + // exporter に空 list を渡して root 形 / schemaVersion を検証する。 + val json = KeyboardLayoutJsonExporter.toJson(emptyList()) + + val root = JsonParser.parseString(json) + assertTrue("root must be object", root.isJsonObject) + + val obj = root.asJsonObject + assertEquals(1, obj["schemaVersion"].asInt) + assertNotNull(obj["layouts"]) + assertTrue("layouts must be array", obj["layouts"].isJsonArray) + assertEquals(0, obj["layouts"].asJsonArray.size()) + } + + // ----------------------------- + // F. blank / 不正 JSON でも crash しない + // ----------------------------- + @Test + fun parse_blankString_returnsEmptyInputFailure() { + assertTrue(KeyboardLayoutJsonImporter.parse("") is KeyboardLayoutImportResult.Failure) + assertTrue(KeyboardLayoutJsonImporter.parse(" ") is KeyboardLayoutImportResult.Failure) + } + + @Test + fun parse_invalidJson_returnsInvalidJsonFailure() { + val result = KeyboardLayoutJsonImporter.parse("{") + assertTrue(result is KeyboardLayoutImportResult.Failure) + assertTrue((result as KeyboardLayoutImportResult.Failure).error is KeyboardLayoutImportError.MalformedJson) + } + + @Test + fun parse_schemaVersion1_withMissingLayouts_returnsUnsupportedFormat() { + val json = """{ "schemaVersion": 1 }""" + val result = KeyboardLayoutJsonImporter.parse(json) + assertTrue(result is KeyboardLayoutImportResult.Failure) + assertEquals( + KeyboardLayoutImportError.UnsupportedFormat, + (result as KeyboardLayoutImportResult.Failure).error + ) + } + + // ----------------------------- + // G. 旧形式 -> 正規化 round-trip 的に Repository 渡せるか + // ----------------------------- + @Test + fun parse_legacyArrayWithoutSpacers_producesNonNullSpacersForRepository() { + val json = "[$minimalLayoutJson]" + + val result = parseSuccessLayouts(json) + + // Repository 側で spacers.map { ... } が呼ばれても落ちないことを保証するため、 + // 必ず non-null List が入っていることを確認する。 + val spacers = result.single().spacers + // map() を呼べる = non-null + spacers.map { it.itemIdentifier } + } + + @Test + fun backupImporter_sharedPreferencesXmlWrapper_extractsEscapedJsonPayload() { + val escapedJson = "[$minimalLayoutJson]" + .replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + .replace("'", "'") + val xml = """ + + + $escapedJson + + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(xml) + + val layouts = result.layoutsOrThrow() + assertEquals(1, layouts.size) + assertEquals("TestKeyboard", layouts.single().layout.name) + } + + @Test + fun backupImporter_xmlWithMultipleStrings_prefersKnownKey() { + val unrelatedLayout = minimalLayoutJson.replace("TestKeyboard", "WrongKeyboard") + val xml = """ + + [$unrelatedLayout] + [$minimalLayoutJson] + + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(xml) + + assertEquals("TestKeyboard", result.layoutsOrThrow().single().layout.name) + } + + @Test + fun backupImporter_xmlWithoutKnownKey_usesLayoutLikeJsonString() { + val xml = """ + + [$minimalLayoutJson] + + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(xml) + + assertEquals("TestKeyboard", result.layoutsOrThrow().single().layout.name) + } + + @Test + fun backupImporter_xmlWithoutPayload_returnsNoLayoutPayloadFound() { + val result = KeyboardLayoutBackupImporter.importText( + """{"hello":"world"}""" + ) + + assertFailure(result, KeyboardLayoutImportError.NoLayoutPayloadFound) + } + + @Test + fun backupImporter_invalidXml_returnsInvalidXml() { + val result = KeyboardLayoutBackupImporter.importText("""[]""") + + assertTrue(result is KeyboardLayoutImportResult.Failure) + assertTrue((result as KeyboardLayoutImportResult.Failure).error is KeyboardLayoutImportError.InvalidXml) + } + + @Test + fun backupImporter_unsupportedPlainText_returnsUnsupportedFormat() { + assertFailure( + KeyboardLayoutBackupImporter.importText("this is not json"), + KeyboardLayoutImportError.UnsupportedFormat + ) + } + + @Test + fun backupImporter_emptyInput_returnsEmptyInput() { + assertFailure( + KeyboardLayoutBackupImporter.importText("\uFEFF\u0000 "), + KeyboardLayoutImportError.EmptyInput + ) + } + + @Test + fun normalize_missingSpacersAndKeyIdentifier_generatesWarningsAndIds() { + val json = """ + [ + { + "layout": { + "layoutId": 14, + "name": "TestKeyboard", + "columnCount": 0, + "rowCount": 0, + "isRomaji": false, + "createdAt": 0 + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 90, + "ownerLayoutId": 14, + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": false, + "drawableResId": null, + "action": null + }, + "flicks": [] + } + ] + } + ] + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(json) + val success = result as KeyboardLayoutImportResult.Success + + val layout = success.layouts.single() + assertEquals(1, layout.layout.rowCount) + assertEquals(1, layout.layout.columnCount) + assertEquals(0L, layout.layout.layoutId) + assertEquals(0L, layout.keysWithFlicks.single().key.keyId) + assertEquals(0L, layout.keysWithFlicks.single().key.ownerLayoutId) + assertFalse(layout.keysWithFlicks.single().key.keyIdentifier.isBlank()) + assertTrue(success.warnings.any { it is KeyboardLayoutImportWarning.MissingSpacerListTreatedAsEmpty }) + assertTrue(success.warnings.any { it is KeyboardLayoutImportWarning.MissingKeyIdentifierGenerated }) + assertTrue(success.warnings.any { it is KeyboardLayoutImportWarning.MissingLayoutIdentifierGenerated }) + assertTrue(success.warnings.any { it is KeyboardLayoutImportWarning.InvalidRowColumnCorrected }) + } + + @Test + fun normalize_invalidSpan_isCorrectedWithWarning() { + val json = """ + [ + { + "layout": { + "layoutId": 14, + "name": "TestKeyboard", + "columnCount": 1, + "rowCount": 1, + "isRomaji": false, + "stableId": "stable-1" + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 90, + "ownerLayoutId": 14, + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 0, + "colSpan": -2, + "keyType": "NORMAL", + "isSpecialKey": false, + "drawableResId": null, + "keyIdentifier": "key-1", + "action": null + }, + "flicks": [] + } + ], + "spacers": [] + } + ] + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(json) as KeyboardLayoutImportResult.PartialSuccess + + assertEquals(1, result.layouts.size) + assertEquals(emptyList(), result.layouts.single().keysWithFlicks) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.InvalidKeyPlacement }) + } + + @Test + fun parse_legacyBackup_preservesActionsAndDirectionsAndEmptyFlicks() { + val json = """ + [ + { + "layout": { + "layoutId": 14, + "name": "ActionKeyboard", + "columnCount": 4, + "rowCount": 2, + "isRomaji": false, + "stableId": "stable-1" + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 1, + "ownerLayoutId": 14, + "label": "", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "PETAL_FLICK", + "isSpecialKey": true, + "drawableResId": null, + "keyIdentifier": "left", + "action": "MoveCursorLeft" + }, + "flicks": [ + { "ownerKeyId": 1, "stateIndex": 0, "flickDirection": "TAP", "actionType": "DELETE", "actionValue": null }, + { "ownerKeyId": 1, "stateIndex": 0, "flickDirection": "UP", "actionType": "MOVE_CURSOR_RIGHT", "actionValue": null }, + { "ownerKeyId": 1, "stateIndex": 0, "flickDirection": "UP_LEFT_FAR", "actionType": "SWITCH_TO_NEXT_IME", "actionValue": null } + ] + }, + { + "key": { + "keyId": 2, + "ownerLayoutId": 14, + "label": "", + "row": 0, + "column": 1, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": true, + "drawableResId": null, + "keyIdentifier": "right", + "action": "MoveCursorRight" + }, + "flicks": [] + } + ] + } + ] + """.trimIndent() + + val layout = KeyboardLayoutBackupImporter.importText(json).layoutsOrThrow().single() + + assertEquals(2, layout.keysWithFlicks.size) + assertEquals("MoveCursorLeft", layout.keysWithFlicks[0].key.action) + assertEquals("MoveCursorRight", layout.keysWithFlicks[1].key.action) + assertEquals(emptyList(), layout.keysWithFlicks[1].flicks) + assertEquals("TAP", layout.keysWithFlicks[0].flicks[0].flickDirection.name) + assertEquals("UP", layout.keysWithFlicks[0].flicks[1].flickDirection.name) + assertEquals("UP_LEFT_FAR", layout.keysWithFlicks[0].flicks[2].flickDirection.name) + assertEquals("DELETE", layout.keysWithFlicks[0].flicks[0].actionType) + assertEquals("MOVE_CURSOR_RIGHT", layout.keysWithFlicks[0].flicks[1].actionType) + assertEquals("SWITCH_TO_NEXT_IME", layout.keysWithFlicks[0].flicks[2].actionType) + } + + @Test + fun parse_legacyResource_normalizesKeyAndFlicks() { + val result = KeyboardLayoutBackupImporter.importText( + resourceText("custom_keyboard/legacy_keyboard_layouts_backup_v0.json") + ) + + val layout = result.layoutsOrThrow().single() + assertEquals("メールとか", layout.layout.name) + assertEquals(1, layout.keysWithFlicks.size) + assertEquals(" メール", layout.keysWithFlicks.single().key.label) + assertEquals(1, layout.keysWithFlicks.single().flicks.size) + assertEquals("example@example.com", layout.keysWithFlicks.single().flicks.single().actionValue) + assertEquals(emptyList(), layout.spacers) + } + + @Test + fun parse_v1Resource_withSpacers_importsSpacer() { + val result = KeyboardLayoutBackupImporter.importText( + resourceText("custom_keyboard/keyboard_layouts_backup_v1_with_spacers.json") + ) + + val layout = result.layoutsOrThrow().single() + assertEquals(1, layout.spacers.size) + assertEquals("spacer-1", layout.spacers.single().itemIdentifier) + } + + @Test + fun parse_unknownFields_areIgnored() { + val result = KeyboardLayoutBackupImporter.importText( + resourceText("custom_keyboard/keyboard_layouts_backup_v1.json") + ) + + assertEquals("V1 Keyboard", result.layoutsOrThrow().single().layout.name) + } + + @Test + fun parse_missingLayout_isSkippedWithStructuredError() { + val json = """ + [ + { "keysWithFlicks": [] }, + $minimalLayoutJson + ] + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(json) as KeyboardLayoutImportResult.PartialSuccess + + assertEquals(1, result.layouts.size) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.MissingLayout }) + } + + @Test + fun parse_missingKeyItem_keepsLayoutAndReportsMissingKeys() { + val json = """ + [ + { + "layout": { + "layoutId": 1, + "name": "MissingKey", + "columnCount": 2, + "rowCount": 1 + }, + "keysWithFlicks": [ { "flicks": [] } ] + } + ] + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(json) as KeyboardLayoutImportResult.PartialSuccess + + assertEquals(1, result.layouts.size) + assertEquals(emptyList(), result.layouts.single().keysWithFlicks) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.MissingKeys }) + } + + @Test + fun parse_brokenOwnerKeyId_skipsOnlyBrokenFlick() { + val json = """ + [ + { + "layout": { + "layoutId": 14, + "name": "BrokenFlickOwner", + "columnCount": 2, + "rowCount": 1 + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 10, + "ownerLayoutId": 14, + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "keyIdentifier": "key-a" + }, + "flicks": [ + { "ownerKeyId": 10, "stateIndex": 0, "flickDirection": "TAP", "actionType": "INPUT_TEXT", "actionValue": "ok" }, + { "ownerKeyId": 999, "stateIndex": 0, "flickDirection": "UP", "actionType": "INPUT_TEXT", "actionValue": "ng" } + ] + } + ] + } + ] + """.trimIndent() + + val result = KeyboardLayoutBackupImporter.importText(json) as KeyboardLayoutImportResult.PartialSuccess + val key = result.layouts.single().keysWithFlicks.single() + + assertEquals(1, key.flicks.size) + assertEquals("ok", key.flicks.single().actionValue) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.BrokenOwnerReference }) + } + + @Test + fun parse_invalidMixedBackup_importsValidLayoutAndReportsErrors() { + val result = KeyboardLayoutBackupImporter.importText( + resourceText("custom_keyboard/invalid_mixed_backup.json") + ) as KeyboardLayoutImportResult.PartialSuccess + + assertEquals(1, result.layouts.size) + assertEquals("Valid Mixed", result.layouts.single().layout.name) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.MissingLayout }) + assertTrue(result.errors.any { it is KeyboardLayoutImportError.InvalidKeyPlacement }) + } + + @Test + fun importExportDtos_pinEveryFieldWithSerializedName() { + val dtoClasses = listOf( + KeyboardLayoutExportFileDto::class.java, + KeyboardLayoutExportDto::class.java, + KeyboardLayoutDto::class.java, + KeyWithFlicksExportDto::class.java, + KeyDefinitionDto::class.java, + FlickMappingDto::class.java, + CircularFlickMappingDto::class.java, + TwoStepFlickMappingDto::class.java, + LongPressFlickMappingDto::class.java, + TwoStepLongPressMappingDto::class.java, + SpacerDefinitionDto::class.java + ) + + dtoClasses.forEach { clazz -> + clazz.declaredFields + .filterNot { it.name.startsWith("\$") } + .forEach { field -> + assertNotNull( + "${clazz.simpleName}.${field.name} must have @SerializedName", + field.getAnnotation(SerializedName::class.java) + ) + } + } + } + + private fun parseSuccessLayouts(json: String): List { + return KeyboardLayoutJsonImporter.parse(json).layoutsOrThrow() + } + + private fun KeyboardLayoutImportResult.layoutsOrThrow(): List { + return when (this) { + is KeyboardLayoutImportResult.Success -> layouts + is KeyboardLayoutImportResult.PartialSuccess -> layouts + is KeyboardLayoutImportResult.Failure -> error("Expected import success, got $error") + } + } + + private fun assertFailure( + result: KeyboardLayoutImportResult, + expectedError: KeyboardLayoutImportError + ) { + assertTrue(result is KeyboardLayoutImportResult.Failure) + assertEquals(expectedError, (result as KeyboardLayoutImportResult.Failure).error) + } + + private fun resourceText(path: String): String { + return requireNotNull(javaClass.classLoader?.getResource(path)) { + "missing test resource: $path" + }.readText() + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModelFlexiblePlacementTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModelFlexiblePlacementTest.kt new file mode 100644 index 000000000..4de8658e5 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyboardEditorViewModelFlexiblePlacementTest.kt @@ -0,0 +1,175 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.ui + +import com.kazumaproject.custom_keyboard.data.FlickAction +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.SpacerItem +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database.KeyboardLayoutDao +import com.kazumaproject.markdownhelperkeyboard.repository.KeyboardRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class KeyboardEditorViewModelFlexiblePlacementTest { + + private fun viewModel(): KeyboardEditorViewModel = + KeyboardEditorViewModel(KeyboardRepository(mock(KeyboardLayoutDao::class.java))) + + @Test + fun qwertyKeyEdit_keepsHalfUnitPlacementAndDoesNotAddEmptyKeys() { + val viewModel = viewModel() + viewModel.applyTemplate(KeyboardDefaultLayouts.createQwertyTemplateLayout()) + + val beforeLayout = viewModel.uiState.value.layout + val beforeItem = beforeLayout.items.filterIsInstance() + .first { it.keyData.keyId == "qwerty_key_a" } + val beforeSpacerCount = beforeLayout.items.count { it is SpacerItem } + val beforeKeyCount = beforeLayout.keys.size + + viewModel.updateKeyAndMappings( + newKeyData = beforeItem.keyData.copy(label = "A", action = KeyAction.Text("A")), + flickMap = emptyMap(), + twoStepMap = emptyMap(), + longPressFlickMap = emptyMap(), + twoStepLongPressMap = emptyMap() + ) + + val afterLayout = viewModel.uiState.value.layout + val afterItem = afterLayout.items.filterIsInstance() + .first { it.keyData.keyId == "qwerty_key_a" } + + assertEquals(1, beforeItem.placement.columnUnits) + assertEquals(beforeItem.placement.rowUnits, afterItem.placement.rowUnits) + assertEquals(beforeItem.placement.columnUnits, afterItem.placement.columnUnits) + assertEquals(beforeItem.placement.rowSpanUnits, afterItem.placement.rowSpanUnits) + assertEquals(beforeItem.placement.columnSpanUnits, afterItem.placement.columnSpanUnits) + assertEquals("A", afterItem.keyData.label) + assertEquals(beforeSpacerCount, afterLayout.items.count { it is SpacerItem }) + assertEquals(beforeKeyCount, afterLayout.keys.size) + } + + @Test + fun qwertyKeyResize_rejectsSpacerOverlap() { + val viewModel = viewModel() + viewModel.applyTemplate(KeyboardDefaultLayouts.createQwertyTemplateLayout()) + + val beforeLayout = viewModel.uiState.value.layout + val shift = beforeLayout.items.filterIsInstance() + .first { it.keyData.keyId == "qwerty_shift" } + + viewModel.updateKeyAndMappings( + newKeyData = shift.keyData.copy(colSpan = 2), + flickMap = emptyMap(), + twoStepMap = emptyMap(), + longPressFlickMap = emptyMap(), + twoStepLongPressMap = emptyMap() + ) + + val afterLayout = viewModel.uiState.value.layout + val afterShift = afterLayout.items.filterIsInstance() + .first { it.keyData.keyId == "qwerty_shift" } + + assertEquals(shift.placement, afterShift.placement) + assertEquals(beforeLayout.items, afterLayout.items) + } + + @Test + fun addSpacer_rejectsOverlapWithKey() { + val viewModel = viewModel() + val key = KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + keyType = KeyType.NORMAL, + keyId = "key_a" + ) + viewModel.applyTemplate( + KeyboardLayout( + keys = listOf(key), + flickKeyMaps = emptyMap(), + columnCount = 2, + rowCount = 1 + ) + ) + + assertFalse( + viewModel.addSpacer( + rowUnits = 0, + columnUnits = 0, + rowSpanUnits = 1, + columnSpanUnits = 1 + ) + ) + } + + @Test + fun addUpdateDeleteSpacer_updatesItemsSourceOfTruth() { + val viewModel = viewModel() + viewModel.applyTemplate( + KeyboardLayout( + keys = emptyList(), + flickKeyMaps = emptyMap(), + columnCount = 2, + rowCount = 1 + ) + ) + + assertTrue( + viewModel.addSpacer( + rowUnits = 0, + columnUnits = 0, + rowSpanUnits = 1, + columnSpanUnits = 1 + ) + ) + val addedSpacer = viewModel.uiState.value.layout.items.filterIsInstance().single() + assertEquals(GridPlacement(0, 0, 1, 1), addedSpacer.placement) + + assertTrue( + viewModel.updateSpacerPlacement( + addedSpacer.id, + GridPlacement(rowUnits = 1, columnUnits = 1, rowSpanUnits = 1, columnSpanUnits = 1) + ) + ) + assertEquals( + GridPlacement(1, 1, 1, 1), + viewModel.uiState.value.layout.items.filterIsInstance().single().placement + ) + + assertTrue(viewModel.deleteSpacer(addedSpacer.id)) + assertTrue(viewModel.uiState.value.layout.items.none { it is SpacerItem }) + } + + @Test + fun legacyLayout_keyEditStillUsesLegacyCellBehavior() { + val viewModel = viewModel() + viewModel.start(-1L) + + val beforeLayout = viewModel.uiState.value.layout + val key = beforeLayout.keys.first() + + viewModel.updateKeyAndMappings( + newKeyData = key.copy(label = "x", action = FlickAction.Input("x").let { KeyAction.Text("x") }), + flickMap = mapOf(FlickDirection.TAP to FlickAction.Input("x")), + twoStepMap = emptyMap(), + longPressFlickMap = emptyMap(), + twoStepLongPressMap = emptyMap() + ) + + val afterLayout = viewModel.uiState.value.layout + val afterKey = afterLayout.keys.first { it.keyId == key.keyId } + assertEquals("x", afterKey.label) + assertEquals(beforeLayout.columnCount * 2, afterLayout.columnUnitCount) + assertEquals(beforeLayout.rowCount * 2, afterLayout.rowUnitCount) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt new file mode 100644 index 000000000..da523c66a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt @@ -0,0 +1,163 @@ +package com.kazumaproject.markdownhelperkeyboard.repository + +import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.Mockito.mock + +class KeyboardRepositoryGridPlacementTest { + + private val repository = KeyboardRepository(mock(KeyboardLayoutDao::class.java)) + + @Test + fun convertRoundTrip_preservesNormalTextActionAndGridPlacement() { + val q = KeyData( + label = "q", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("q"), + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyId = "q_key" + ) + val a = KeyData( + label = "a", + row = 1, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyId = "a_key" + ) + val layout = KeyboardLayout( + keys = listOf(q, a), + flickKeyMaps = emptyMap(), + columnCount = 10, + rowCount = 4, + items = listOf( + KeyItem("q_key", q, GridPlacement(rowUnits = 0, columnUnits = 0)), + KeyItem("a_key", a, GridPlacement(rowUnits = 2, columnUnits = 1)) + ), + columnUnitCount = 20, + rowUnitCount = 8 + ) + + val dbKeys = convertToDbKeys(layout) + val restored = convertToUiLayout(dbKeys) + + val restoredA = restored.keys.first { it.keyId == "a_key" } + val restoredAItem = restored.items.filterIsInstance().first { it.keyData.keyId == "a_key" } + + assertEquals(KeyAction.Text("q"), restored.keys.first { it.keyId == "q_key" }.action) + assertEquals(KeyAction.Text("a"), restoredA.action) + assertFalse(restoredA.isSpecialKey) + assertEquals(1, restoredAItem.placement.columnUnits) + } + + @Test + fun convertToUiModel_fallsBackTextActionOnlyForNormalNonSpecialKeys() { + val normal = keyDefinition( + identifier = "normal_key", + label = "x", + keyType = KeyType.NORMAL, + isSpecialKey = false, + action = null + ) + val flick = keyDefinition( + identifier = "flick_key", + label = "y", + keyType = KeyType.PETAL_FLICK, + isSpecialKey = false, + action = null + ) + val special = keyDefinition( + identifier = "special_key", + label = "Del", + keyType = KeyType.NORMAL, + isSpecialKey = true, + action = null + ) + + val restored = convertToUiLayout(listOf(normal, flick, special)) + + assertEquals(KeyAction.Text("x"), restored.keys.first { it.keyId == "normal_key" }.action) + assertNull(restored.keys.first { it.keyId == "flick_key" }.action) + assertNull(restored.keys.first { it.keyId == "special_key" }.action) + } + + private fun convertToDbKeys(layout: KeyboardLayout): List { + val method = KeyboardRepository::class.java.getDeclaredMethod( + "convertToDbModel", + KeyboardLayout::class.java + ) + method.isAccessible = true + val parts = method.invoke(repository, layout) + val keysField = parts.javaClass.getDeclaredField("keys") + keysField.isAccessible = true + @Suppress("UNCHECKED_CAST") + return keysField.get(parts) as List + } + + private fun convertToUiLayout(keys: List): KeyboardLayout { + val method = KeyboardRepository::class.java.getDeclaredMethod( + "convertToUiModel", + FullKeyboardLayout::class.java + ) + method.isAccessible = true + return method.invoke( + repository, + FullKeyboardLayout( + layout = CustomKeyboardLayout( + layoutId = 1, + name = "test", + columnCount = 10, + rowCount = 4 + ), + keysWithFlicks = keys.map { + KeyWithFlicks( + key = it, + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + } + ) + ) as KeyboardLayout + } + + private fun keyDefinition( + identifier: String, + label: String, + keyType: KeyType, + isSpecialKey: Boolean, + action: String? + ): KeyDefinition { + return KeyDefinition( + keyId = 0, + ownerLayoutId = 1, + label = label, + row = 0, + column = 0, + keyType = keyType, + isSpecialKey = isSpecialKey, + keyIdentifier = identifier, + action = action + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryImportLayoutsTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryImportLayoutsTest.kt new file mode 100644 index 000000000..1207f656b --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryImportLayoutsTest.kt @@ -0,0 +1,227 @@ +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.CustomKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FlickMapping +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyDefinition +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database.KeyboardLayoutDao +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.ImportableKeyWithFlicks +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.ImportableKeyboardLayout +import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export.KeyboardLayoutImportResult +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * [KeyboardRepository.importLayouts] が新 [ImportableKeyboardLayout] を受け取り、 + * 既存名重複時に名前を変更し、spacers が空でもクラッシュしないことを保証する。 + */ +class KeyboardRepositoryImportLayoutsTest { + + private val dao: KeyboardLayoutDao = mock() + private val repository = KeyboardRepository(dao) + + @Test + fun importLayouts_emptySpacers_doesNotCrash() = runBlocking { + whenever(dao.getMaxSortOrder()).thenReturn(10) + whenever(dao.findLayoutByName(any())).thenReturn(null) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + + val importable = ImportableKeyboardLayout( + layout = layout(name = "MyKeyboard", stableId = "stable-id-1"), + keysWithFlicks = listOf(keyWithFlicks("k1", "あ")), + spacers = emptyList() + ) + + // ここで [fullLayout.spacers.map { ... }] 由来の NPE が出ない事が + // 後方互換修正の最重要回帰テスト。 + repository.importLayouts(listOf(importable)) + + val layoutCaptor = argumentCaptor() + verify(dao).insertFullKeyboardLayout( + layoutCaptor.capture(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + + val inserted = layoutCaptor.firstValue + assertEquals("MyKeyboard", inserted.name) + assertEquals(0L, inserted.layoutId) + assertEquals(11, inserted.sortOrder) + assertEquals("stable-id-1", inserted.stableId) + assertNotNull(inserted.createdAt) + } + + @Test + fun importLayouts_duplicateName_isRenamedWithCounterSuffix() = runBlocking { + whenever(dao.getMaxSortOrder()).thenReturn(0) + + // 1 回目: name = "MyKeyboard" -> 衝突 + // 2 回目: name = "MyKeyboard (1)" -> 衝突無し + whenever(dao.findLayoutByName("MyKeyboard")).thenReturn( + CustomKeyboardLayout( + layoutId = 99, + name = "MyKeyboard", + columnCount = 5, + rowCount = 4, + stableId = "existing" + ) + ) + whenever(dao.findLayoutByName("MyKeyboard (1)")).thenReturn(null) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + + val importable = ImportableKeyboardLayout( + layout = layout(name = "MyKeyboard", stableId = "stable-id-2"), + keysWithFlicks = emptyList(), + spacers = emptyList() + ) + + repository.importLayouts(listOf(importable)) + + val layoutCaptor = argumentCaptor() + verify(dao).insertFullKeyboardLayout( + layoutCaptor.capture(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + + assertEquals("MyKeyboard (1)", layoutCaptor.firstValue.name) + } + + @Test + fun importLayouts_blankStableId_isReplacedWithRandom() = runBlocking { + whenever(dao.getMaxSortOrder()).thenReturn(0) + whenever(dao.findLayoutByName(any())).thenReturn(null) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + + val importable = ImportableKeyboardLayout( + layout = layout(name = "MyKeyboard", stableId = ""), + keysWithFlicks = emptyList(), + spacers = emptyList() + ) + + repository.importLayouts(listOf(importable)) + + val layoutCaptor = argumentCaptor() + verify(dao).insertFullKeyboardLayout( + layoutCaptor.capture(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + + val inserted = layoutCaptor.firstValue + // blank stableId なら自動で UUID が割当てられる + assert(inserted.stableId.isNotBlank()) + } + + @Test + fun importLayouts_legacyNumericIds_areResetBeforeDaoInsert() = runBlocking { + whenever(dao.getMaxSortOrder()).thenReturn(0) + whenever(dao.findLayoutByName(any())).thenReturn(null) + whenever(dao.findLayoutByStableId(any())).thenReturn(null) + + val importable = ImportableKeyboardLayout( + layout = layout(name = "LegacyKeyboard", stableId = "legacy-stable").copy(layoutId = 14), + keysWithFlicks = listOf( + keyWithFlicks("legacy-key", "あ").copy( + key = keyWithFlicks("legacy-key", "あ").key.copy( + keyId = 99, + ownerLayoutId = 14 + ), + flicks = listOf( + FlickMapping( + ownerKeyId = 99, + stateIndex = 0, + flickDirection = FlickDirection.TAP, + actionType = "DELETE", + actionValue = null + ) + ) + ) + ), + spacers = emptyList() + ) + + val result = repository.importLayouts(listOf(importable)) + + assertTrue(result is KeyboardLayoutImportResult.Success) + val keysCaptor = argumentCaptor>() + val flicksMapCaptor = argumentCaptor>>() + verify(dao).insertFullKeyboardLayout( + any(), + keysCaptor.capture(), + flicksMapCaptor.capture(), + any(), + any(), + any(), + any(), + any() + ) + assertEquals(0L, keysCaptor.firstValue.single().keyId) + assertEquals(0L, keysCaptor.firstValue.single().ownerLayoutId) + assertEquals(0L, flicksMapCaptor.firstValue.getValue("legacy-key").single().ownerKeyId) + } + + // ----------------------------- + // helpers + // ----------------------------- + + private fun layout(name: String, stableId: String): CustomKeyboardLayout { + return CustomKeyboardLayout( + layoutId = 0, + name = name, + columnCount = 5, + rowCount = 4, + isRomaji = false, + isDirectMode = false, + createdAt = 0L, + sortOrder = 0, + stableId = stableId + ) + } + + private fun keyWithFlicks(identifier: String, label: String): ImportableKeyWithFlicks { + return ImportableKeyWithFlicks( + key = KeyDefinition( + keyId = 0, + ownerLayoutId = 0, + label = label, + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = false, + drawableResId = null, + keyIdentifier = identifier, + action = null + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + } +} diff --git a/app/src/test/resources/custom_keyboard/invalid_mixed_backup.json b/app/src/test/resources/custom_keyboard/invalid_mixed_backup.json new file mode 100644 index 000000000..1e9bacbd1 --- /dev/null +++ b/app/src/test/resources/custom_keyboard/invalid_mixed_backup.json @@ -0,0 +1,46 @@ +[ + { + "layout": { + "layoutId": 41, + "name": "Valid Mixed", + "columnCount": 2, + "rowCount": 1, + "isRomaji": false + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 201, + "ownerLayoutId": 41, + "keyIdentifier": "valid-mixed-key", + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": false + }, + "flicks": [] + }, + { + "key": { + "keyId": 202, + "ownerLayoutId": 41, + "keyIdentifier": "invalid-placement-key", + "label": "B", + "row": 0, + "column": 2, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": false + }, + "flicks": [] + } + ] + }, + { + "keysWithFlicks": [] + } +] diff --git a/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1.json b/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1.json new file mode 100644 index 000000000..7e1635e80 --- /dev/null +++ b/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "exportedBy": "unit-test", + "layouts": [ + { + "layout": { + "layoutId": 21, + "name": "V1 Keyboard", + "columnCount": 3, + "rowCount": 2, + "isRomaji": false, + "isDirectMode": true, + "createdAt": 1754191047725, + "stableId": "stable-v1", + "futureLayoutField": "ignored" + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 91, + "ownerLayoutId": 21, + "keyIdentifier": "v1-key-1", + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": false, + "futureKeyField": "ignored" + }, + "flicks": [ + { + "ownerKeyId": 91, + "stateIndex": 0, + "flickDirection": "TAP", + "actionType": "INPUT_TEXT", + "actionValue": "A", + "futureFlickField": "ignored" + } + ] + } + ], + "unknownLayoutItemList": [] + } + ] +} diff --git a/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1_with_spacers.json b/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1_with_spacers.json new file mode 100644 index 000000000..235fd0823 --- /dev/null +++ b/app/src/test/resources/custom_keyboard/keyboard_layouts_backup_v1_with_spacers.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "layouts": [ + { + "layout": { + "layoutId": 31, + "name": "V1 With Spacer", + "columnCount": 4, + "rowCount": 2, + "isRomaji": false, + "isDirectMode": false, + "createdAt": 1754191047725, + "stableId": "stable-v1-spacer" + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 101, + "ownerLayoutId": 31, + "keyIdentifier": "v1-spacer-key-1", + "label": "A", + "row": 0, + "column": 0, + "rowSpan": 1, + "colSpan": 1, + "keyType": "NORMAL", + "isSpecialKey": false + }, + "flicks": [] + } + ], + "spacers": [ + { + "spacerId": 1, + "ownerLayoutId": 31, + "itemIdentifier": "spacer-1", + "rowUnits": 0, + "columnUnits": 2, + "rowSpanUnits": 2, + "columnSpanUnits": 2, + "sortOrder": 0 + } + ] + } + ] +} diff --git a/app/src/test/resources/custom_keyboard/legacy_keyboard_layouts_backup_v0.json b/app/src/test/resources/custom_keyboard/legacy_keyboard_layouts_backup_v0.json new file mode 100644 index 000000000..c652ed27e --- /dev/null +++ b/app/src/test/resources/custom_keyboard/legacy_keyboard_layouts_backup_v0.json @@ -0,0 +1,37 @@ +[ + { + "layout": { + "layoutId": 14, + "name": "メールとか", + "columnCount": 4, + "rowCount": 2, + "isRomaji": false, + "createdAt": 1754191047725 + }, + "keysWithFlicks": [ + { + "key": { + "keyId": 556, + "ownerLayoutId": 14, + "keyIdentifier": "83df0e1a-b097-4b83-8491-8ac07131c5dc", + "label": " メール", + "row": 0, + "column": 1, + "rowSpan": 1, + "colSpan": 1, + "keyType": "PETAL_FLICK", + "isSpecialKey": false + }, + "flicks": [ + { + "ownerKeyId": 556, + "stateIndex": 0, + "flickDirection": "TAP", + "actionType": "INPUT_TEXT", + "actionValue": "example@example.com" + } + ] + } + ] + } +] diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyActionMapper.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyActionMapper.kt index ed762bc95..73573a0ad 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyActionMapper.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyActionMapper.kt @@ -11,6 +11,8 @@ data class DisplayAction( object KeyActionMapper { private const val MOVE_TO_CUSTOM_KEYBOARD_PREFIX = "MoveToCustomKeyboard:" + private const val TEXT_PREFIX = "Text:" + private const val INPUT_TEXT_PREFIX = "InputText:" /** * Generates a list of DisplayAction objects using localized strings. @@ -176,6 +178,8 @@ object KeyActionMapper { is KeyAction.SwitchToNumberLayout -> "SwitchToNumber" is KeyAction.ShiftKey -> "ShiftKeyPressed" is KeyAction.CapLockKey -> "CapLockKey" + is KeyAction.Text -> "$TEXT_PREFIX${keyAction.text}" + is KeyAction.InputText -> "$INPUT_TEXT_PREFIX${keyAction.text}" is KeyAction.SwitchRomajiEnglish -> "SwitchRomajiEnglish" is KeyAction.MoveCustomKeyboardTab -> "MoveCustomKeyboardTab" is KeyAction.MoveToCustomKeyboard -> keyAction.stableId @@ -197,6 +201,12 @@ object KeyActionMapper { val stableId = actionString.removePrefix(MOVE_TO_CUSTOM_KEYBOARD_PREFIX) return stableId.takeIf { it.isNotBlank() }?.let { KeyAction.MoveToCustomKeyboard(it) } } + if (actionString?.startsWith(TEXT_PREFIX) == true) { + return KeyAction.Text(actionString.removePrefix(TEXT_PREFIX)) + } + if (actionString?.startsWith(INPUT_TEXT_PREFIX) == true) { + return KeyAction.InputText(actionString.removePrefix(INPUT_TEXT_PREFIX)) + } return when (actionString) { "Delete" -> KeyAction.Delete "Backspace" -> KeyAction.Backspace diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt index b7262e82c..08fa91dc0 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt @@ -88,6 +88,291 @@ data class KeyData( val keyType: KeyType = if (isFlickable) KeyType.CIRCULAR_FLICK else KeyType.NORMAL ) +data class GridPlacement( + val rowUnits: Int, + val columnUnits: Int, + val rowSpanUnits: Int = 2, + val columnSpanUnits: Int = 2 +) + +sealed interface KeyboardLayoutItem { + val id: String + val placement: GridPlacement +} + +data class KeyItem( + override val id: String, + val keyData: KeyData, + override val placement: GridPlacement +) : KeyboardLayoutItem + +data class SpacerItem( + override val id: String, + override val placement: GridPlacement +) : KeyboardLayoutItem + +private fun KeyData.layoutItemId(): String = + keyId?.takeIf { it.isNotBlank() } ?: "key_${row}_${column}_${label}" + +fun KeyData.toKeyItem(): KeyItem = + KeyItem( + id = layoutItemId(), + keyData = this, + placement = GridPlacement( + rowUnits = row * 2, + columnUnits = column * 2, + rowSpanUnits = rowSpan * 2, + columnSpanUnits = colSpan * 2 + ) + ) + +fun halfColumnSpacer(id: String, rowUnits: Int, columnUnits: Int): SpacerItem = + SpacerItem( + id = id, + placement = GridPlacement(rowUnits, columnUnits, rowSpanUnits = 2, columnSpanUnits = 1) + ) + +fun oneColumnSpacer(id: String, rowUnits: Int, columnUnits: Int): SpacerItem = + SpacerItem( + id = id, + placement = GridPlacement(rowUnits, columnUnits, rowSpanUnits = 2, columnSpanUnits = 2) + ) + +fun halfRowSpacer( + id: String, + rowUnits: Int, + columnUnits: Int, + columnSpanUnits: Int +): SpacerItem = + SpacerItem( + id = id, + placement = GridPlacement(rowUnits, columnUnits, rowSpanUnits = 1, columnSpanUnits = columnSpanUnits) + ) + +fun oneRowSpacer( + id: String, + rowUnits: Int, + columnUnits: Int, + columnSpanUnits: Int +): SpacerItem = + SpacerItem( + id = id, + placement = GridPlacement(rowUnits, columnUnits, rowSpanUnits = 2, columnSpanUnits = columnSpanUnits) + ) + +fun KeyboardLayout.itemsForKeys(updatedKeys: List): List { + val keysByItemId = updatedKeys.associateBy { it.layoutItemId() } + val usedIds = mutableSetOf() + + val updatedItems = items.mapNotNull { item -> + when (item) { + is SpacerItem -> item + is KeyItem -> { + val updatedKeyData = keysByItemId[item.id] + if (updatedKeyData != null) { + usedIds += item.id + if ( + updatedKeyData.row == item.keyData.row && + updatedKeyData.column == item.keyData.column && + updatedKeyData.rowSpan == item.keyData.rowSpan && + updatedKeyData.colSpan == item.keyData.colSpan + ) { + item.copy(keyData = updatedKeyData) + } else { + updatedKeyData.toKeyItem() + } + } else { + null + } + } + } + } + + return updatedItems + updatedKeys + .filter { it.layoutItemId() !in usedIds } + .map { it.toKeyItem() } +} + +fun KeyboardLayout.copyWithKeys( + keys: List, + columnCount: Int = this.columnCount, + rowCount: Int = this.rowCount +): KeyboardLayout = + copy( + keys = keys, + columnCount = columnCount, + rowCount = rowCount, + items = itemsForKeys(keys), + columnUnitCount = columnCount * 2, + rowUnitCount = rowCount * 2 + ) + +fun KeyboardLayout.usesFlexiblePlacement(): Boolean { + if (items.any { it is SpacerItem }) return true + + return items.filterIsInstance().any { item -> + item.placement.rowUnits != item.keyData.row * 2 || + item.placement.columnUnits != item.keyData.column * 2 || + item.placement.rowSpanUnits != item.keyData.rowSpan * 2 || + item.placement.columnSpanUnits != item.keyData.colSpan * 2 + } +} + +// ===================================================================== +// Item-based helpers (long-term source of truth: items + GridPlacement) +// +// Use these helpers whenever the layout uses half-cell offsets or +// SpacerItems (QWERTY/AZERTY/Dvorak/Colemak templates and any layout +// where items contain non-(row*2,column*2) placements). +// +// Avoid `copyWithKeys()` for those layouts because it routes through +// KeyData.row/column, which cannot represent half-cell offsets. +// ===================================================================== + +/** + * Replace the item list of this layout while keeping every other field + * intact. `keys` is regenerated from the new items so the two stay in sync, + * and column/row unit counts are kept as the current layout's values. + * + * This is the preferred update primitive when a layout's source of truth is + * `items` + `GridPlacement` (e.g. QWERTY-family templates with half-cell + * offsets and SpacerItems). + */ +fun KeyboardLayout.copyWithItems( + newItems: List +): KeyboardLayout { + val newKeys = newItems.filterIsInstance().map { it.keyData } + return copy( + keys = newKeys, + items = newItems + ) +} + +/** + * Update the [KeyData] of a single [KeyItem] while leaving its placement + * (and every other item) untouched. + * + * Use this when the user edits a key's label / action / flick map etc. + * but the cell it occupies should not change. + */ +fun KeyboardLayout.updateKeyDataKeepingPlacement( + keyId: String, + transform: (KeyData) -> KeyData +): KeyboardLayout { + var changed = false + val newItems = items.map { item -> + when { + item is KeyItem && (item.id == keyId || item.keyData.keyId == keyId) -> { + changed = true + item.copy(keyData = transform(item.keyData)) + } + else -> item + } + } + if (!changed) return this + return copyWithItems(newItems) +} + +/** + * Swap the placements of two [KeyItem]s identified by id (KeyItem.id or + * KeyData.keyId). Item order in the list is preserved; only their + * [GridPlacement]s are exchanged. SpacerItems are left untouched. + * + * This is the placement-based replacement for the legacy "copy KeyData + * with new row/column" swap, which cannot represent half-cell offsets. + * + * Returns the original layout if either id can't be matched, if both ids + * resolve to the same item, or if swapping would produce overlaps / + * out-of-bounds placements (caller can detect "no-op" by reference equality). + */ +fun KeyboardLayout.swapKeyPlacements( + draggedKeyId: String, + targetKeyId: String +): KeyboardLayout { + if (draggedKeyId == targetKeyId) return this + + val keyItems = items.filterIsInstance() + val dragged = keyItems.firstOrNull { + it.id == draggedKeyId || it.keyData.keyId == draggedKeyId + } ?: return this + val target = keyItems.firstOrNull { + it.id == targetKeyId || it.keyData.keyId == targetKeyId + } ?: return this + if (dragged.id == target.id) return this + + val newItems = items.map { item -> + when { + item is KeyItem && item.id == dragged.id -> item.copy(placement = target.placement) + item is KeyItem && item.id == target.id -> item.copy(placement = dragged.placement) + else -> item + } + } + + if (hasPlacementIssues(newItems, rowUnitCount, columnUnitCount)) { + return this + } + return copyWithItems(newItems) +} + +/** + * Returns true if any KeyItem placement overlaps another KeyItem / SpacerItem + * placement, or any item's placement extends outside [rowUnitCount] / + * [columnUnitCount]. + * + * SpacerItems also participate in overlap detection. Editor-created spacers + * are real grid occupants, so they must not overlap keys or other spacers. + */ +fun hasPlacementIssues( + items: List, + rowUnitCount: Int, + columnUnitCount: Int +): Boolean { + items.forEach { item -> + val p = item.placement + if (p.rowUnits < 0 || p.columnUnits < 0) return true + if (p.rowUnits + p.rowSpanUnits > rowUnitCount) return true + if (p.columnUnits + p.columnSpanUnits > columnUnitCount) return true + if (p.rowSpanUnits <= 0 || p.columnSpanUnits <= 0) return true + } + + for (i in items.indices) { + for (j in i + 1 until items.size) { + val a = items[i] + val b = items[j] + if (isPlacementOverlapping(a.placement, b.placement)) return true + } + } + return false +} + +/** + * Two GridPlacements overlap if their integer-unit rectangles intersect. + * + * Rectangle: + * left = columnUnits + * right = columnUnits + columnSpanUnits + * top = rowUnits + * bottom = rowUnits + rowSpanUnits + */ +fun isPlacementOverlapping(p1: GridPlacement, p2: GridPlacement): Boolean { + val p1Left = p1.columnUnits + val p1Right = p1.columnUnits + p1.columnSpanUnits + val p1Top = p1.rowUnits + val p1Bottom = p1.rowUnits + p1.rowSpanUnits + + val p2Left = p2.columnUnits + val p2Right = p2.columnUnits + p2.columnSpanUnits + val p2Top = p2.rowUnits + val p2Bottom = p2.rowUnits + p2.rowSpanUnits + + return !( + p1Right <= p2Left || + p1Left >= p2Right || + p1Bottom <= p2Top || + p1Top >= p2Bottom + ) +} + data class KeyboardLayout( val keys: List, @@ -101,7 +386,10 @@ data class KeyboardLayout( val twoStepFlickKeyMaps: Map>> = emptyMap(), val longPressFlickKeyMaps: Map> = emptyMap(), val twoStepLongPressKeyMaps: Map>> = emptyMap(), - val hierarchicalFlickMaps: Map = emptyMap() + val hierarchicalFlickMaps: Map = emptyMap(), + val items: List = keys.map { it.toKeyItem() }, + val columnUnitCount: Int = columnCount * 2, + val rowUnitCount: Int = rowCount * 2 ) /** diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt index e02bba8e7..4abd4ea71 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt @@ -2,13 +2,18 @@ package com.kazumaproject.custom_keyboard.layout import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyMode import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardInputMode import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.KeyboardLayoutItem +import com.kazumaproject.custom_keyboard.data.SpacerItem import com.kazumaproject.custom_keyboard.data.TfbiFlickNode +import com.kazumaproject.custom_keyboard.data.copyWithKeys import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection object KeyboardDefaultLayouts { @@ -75,9 +80,9 @@ object KeyboardDefaultLayouts { val deleteKeyLookupKeys = layout.keys .filter { keyData -> keyData.action == KeyAction.Delete || - keyData.keyId == "delete_key" || - keyData.keyId in deleteMapKeys || - (keyData.label.isNotBlank() && keyData.label in deleteMapKeys) + keyData.keyId == "delete_key" || + keyData.keyId in deleteMapKeys || + (keyData.label.isNotBlank() && keyData.label in deleteMapKeys) } .flatMap { keyData -> listOfNotNull(keyData.keyId, keyData.label) } .toSet() + deleteMapKeys @@ -115,9 +120,9 @@ object KeyboardDefaultLayouts { } else { layout.keys.map { keyData -> val isDeleteKey = keyData.action == KeyAction.Delete || - keyData.keyId == "delete_key" || - keyData.keyId in deleteKeyLookupKeys || - (keyData.label.isNotBlank() && keyData.label in deleteKeyLookupKeys) + keyData.keyId == "delete_key" || + keyData.keyId in deleteKeyLookupKeys || + (keyData.label.isNotBlank() && keyData.label in deleteKeyLookupKeys) if (isDeleteKey) { keyData.copy(action = KeyAction.Delete, keyType = KeyType.NORMAL) } else { @@ -126,8 +131,7 @@ object KeyboardDefaultLayouts { } } - return layout.copy( - keys = filteredKeys, + return layout.copyWithKeys(filteredKeys).copy( flickKeyMaps = filteredFlickKeyMaps, circularFlickKeyMaps = filteredCircularFlickKeyMaps ) @@ -358,7 +362,7 @@ object KeyboardDefaultLayouts { this[keyIndex] = newKey } - return baseLayout.copy(keys = newKeys) + return baseLayout.copyWithKeys(newKeys) } private fun createHiraganaLayoutToggle( @@ -1177,7 +1181,7 @@ object KeyboardDefaultLayouts { FlickDirection.UP to FlickAction.Input("?"), FlickDirection.UP_RIGHT_FAR to FlickAction.Input("!"), FlickDirection.DOWN to FlickAction.Input("…"), - ) + ) val flickMaps: MutableMap>> = if (deleteKeyFlickSettings.hasFlickActions) { @@ -1714,31 +1718,31 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("B"), FlickDirection.UP to FlickAction.Input("C"), - ) + ) val defUpper = mapOf( FlickDirection.TAP to FlickAction.Input("D"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("E"), FlickDirection.UP to FlickAction.Input("F"), - ) + ) val ghiUpper = mapOf( FlickDirection.TAP to FlickAction.Input("G"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("H"), FlickDirection.UP to FlickAction.Input("I"), - ) + ) val jklUpper = mapOf( FlickDirection.TAP to FlickAction.Input("J"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("K"), FlickDirection.UP to FlickAction.Input("L"), - ) + ) val mnoUpper = mapOf( FlickDirection.TAP to FlickAction.Input("M"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("N"), FlickDirection.UP to FlickAction.Input("O"), - ) + ) val pqrsUpper = mapOf( FlickDirection.TAP to FlickAction.Input("P"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("Q"), @@ -1751,7 +1755,7 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("U"), FlickDirection.UP to FlickAction.Input("V"), - ) + ) val wxyzUpper = mapOf( FlickDirection.TAP to FlickAction.Input("W"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("X"), @@ -2006,31 +2010,31 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("B"), FlickDirection.UP to FlickAction.Input("C"), - ) + ) val defUpper = mapOf( FlickDirection.TAP to FlickAction.Input("D"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("E"), FlickDirection.UP to FlickAction.Input("F"), - ) + ) val ghiUpper = mapOf( FlickDirection.TAP to FlickAction.Input("G"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("H"), FlickDirection.UP to FlickAction.Input("I"), - ) + ) val jklUpper = mapOf( FlickDirection.TAP to FlickAction.Input("J"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("K"), FlickDirection.UP to FlickAction.Input("L"), - ) + ) val mnoUpper = mapOf( FlickDirection.TAP to FlickAction.Input("M"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("N"), FlickDirection.UP to FlickAction.Input("O"), - ) + ) val pqrsUpper = mapOf( FlickDirection.TAP to FlickAction.Input("P"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("Q"), @@ -2043,7 +2047,7 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("U"), FlickDirection.UP to FlickAction.Input("V"), - ) + ) val wxyzUpper = mapOf( FlickDirection.TAP to FlickAction.Input("W"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("X"), @@ -2333,31 +2337,31 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("B"), FlickDirection.UP to FlickAction.Input("C"), - ) + ) val defUpper = mapOf( FlickDirection.TAP to FlickAction.Input("D"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("E"), FlickDirection.UP to FlickAction.Input("F"), - ) + ) val ghiUpper = mapOf( FlickDirection.TAP to FlickAction.Input("G"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("H"), FlickDirection.UP to FlickAction.Input("I"), - ) + ) val jklUpper = mapOf( FlickDirection.TAP to FlickAction.Input("J"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("K"), FlickDirection.UP to FlickAction.Input("L"), - ) + ) val mnoUpper = mapOf( FlickDirection.TAP to FlickAction.Input("M"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("N"), FlickDirection.UP to FlickAction.Input("O"), - ) + ) val pqrsUpper = mapOf( FlickDirection.TAP to FlickAction.Input("P"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("Q"), @@ -2370,7 +2374,7 @@ object KeyboardDefaultLayouts { FlickDirection.UP_LEFT_FAR to FlickAction.Input("U"), FlickDirection.UP to FlickAction.Input("V"), - ) + ) val wxyzUpper = mapOf( FlickDirection.TAP to FlickAction.Input("W"), FlickDirection.UP_LEFT_FAR to FlickAction.Input("X"), @@ -3738,39 +3742,39 @@ object KeyboardDefaultLayouts { val flickMaps: Map>> = mutableMapOf>>( - "1" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("1"))), - "2" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("2"))), - "3" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("3"))), - "4" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("4"))), - "5" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("5"))), - "6" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("6"))), - "7" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("7"))), - "8" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("8"))), - "9" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("9"))), - "0" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("0"))), - "," to listOf( - mapOf( - FlickDirection.TAP to FlickAction.Input(","), - FlickDirection.UP_LEFT_FAR to FlickAction.Input("+"), - FlickDirection.UP to FlickAction.Input("-"), - FlickDirection.UP_RIGHT_FAR to FlickAction.Input("*"), - FlickDirection.DOWN to FlickAction.Input("/"), - ) - ), - "." to listOf( - mapOf( - FlickDirection.TAP to FlickAction.Input("."), - FlickDirection.UP_LEFT_FAR to FlickAction.Input("("), - FlickDirection.UP to FlickAction.Input("%"), - FlickDirection.UP_RIGHT_FAR to FlickAction.Input(")"), - FlickDirection.DOWN to FlickAction.Input("="), + "1" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("1"))), + "2" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("2"))), + "3" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("3"))), + "4" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("4"))), + "5" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("5"))), + "6" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("6"))), + "7" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("7"))), + "8" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("8"))), + "9" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("9"))), + "0" to listOf(mapOf(FlickDirection.TAP to FlickAction.Input("0"))), + "," to listOf( + mapOf( + FlickDirection.TAP to FlickAction.Input(","), + FlickDirection.UP_LEFT_FAR to FlickAction.Input("+"), + FlickDirection.UP to FlickAction.Input("-"), + FlickDirection.UP_RIGHT_FAR to FlickAction.Input("*"), + FlickDirection.DOWN to FlickAction.Input("/"), + ) + ), + "." to listOf( + mapOf( + FlickDirection.TAP to FlickAction.Input("."), + FlickDirection.UP_LEFT_FAR to FlickAction.Input("("), + FlickDirection.UP to FlickAction.Input("%"), + FlickDirection.UP_RIGHT_FAR to FlickAction.Input(")"), + FlickDirection.DOWN to FlickAction.Input("="), + ) ) - ) - ).toMutableMap().apply { - if (deleteKeyFlickSettings.hasFlickActions) { - put("", listOf(createDeleteActionMap(deleteKeyFlickSettings))) + ).toMutableMap().apply { + if (deleteKeyFlickSettings.hasFlickActions) { + put("", listOf(createDeleteActionMap(deleteKeyFlickSettings))) + } } - } return applyDeleteKeyFlickSettings( KeyboardLayout(keys, flickMaps, 4, 4), @@ -4460,6 +4464,271 @@ object KeyboardDefaultLayouts { return KeyboardLayout(keys, flickMaps, 4, 4) } + /** + * 文字キー1個分の安全な keyId サフィックスを生成する。 + * 記号類は読みやすい識別子に変換する。 + */ + private fun safeKeyIdSuffix(char: String): String = when (char) { + "'" -> "quote" + "," -> "comma" + "." -> "period" + ";" -> "semicolon" + else -> char + } + + // ===================================================================== + // Declarative templates (QWERTY / AZERTY / Dvorak / Colemak) + // + // Each alphabet template is defined as a TemplateLayoutSpec that names + // every cell — character keys, special keys, and intra-row Spacers — + // explicitly. The builder converts the spec into KeyboardLayout.items, + // and `keys` is derived from those items so layout.items remains the + // single source of truth (no half-cell information is encoded in + // KeyData.row/column). + // + // Per-template freedom: + // - columnUnitCount may differ (Dvorak uses 24 because Row2 has + // 10 letters + Shift + Delete). + // - Each row chooses its own startColumnUnits and heightUnits. + // - Special keys and Spacers can be placed at any position; Row3 is + // no longer hard-coded as the only place for them. + // + // Bug fix: + // - Editor drag-swap now uses placements from these items; KeyData. + // row/column are *not* the source of truth, so half-cell QWERTY + // placements survive a swap. + // ===================================================================== + + /** Top-level alphabet-template definition. */ + private data class TemplateLayoutSpec( + val idPrefix: String, + val name: String, + val rowUnitCount: Int, + val columnUnitCount: Int, + val rows: List + ) + + /** + * One row of the template. `startColumnUnits` shifts the row right + * (typical home-row offset), and `heightUnits` defaults to 2. + */ + private data class TemplateRowSpec( + val rowUnits: Int, + val startColumnUnits: Int = 0, + val heightUnits: Int = 2, + val items: List + ) + + /** Anything that occupies space in a row. */ + private sealed interface TemplateItemSpec { + val columnSpanUnits: Int + } + + /** Plain character key (label is also the typed text). */ + private data class CharKeySpec( + val label: String, + override val columnSpanUnits: Int = 2 + ) : TemplateItemSpec + + /** Special key (Shift, Delete, Enter, Space, ...). */ + private data class SpecialKeySpec( + val idSuffix: String, + val label: String = "", + val action: KeyAction, + val drawableResId: Int? = null, + override val columnSpanUnits: Int = 2 + ) : TemplateItemSpec + + /** Visual gap inside a row; participates in placement but renders empty. */ + private data class SpacerSpec( + val idSuffix: String, + override val columnSpanUnits: Int + ) : TemplateItemSpec + + /** + * Convert a [TemplateLayoutSpec] into a [KeyboardLayout]. + * + * - Each character/special key becomes a [KeyItem] whose + * [GridPlacement] reflects the cumulative column position. + * - Each [SpacerSpec] becomes a [SpacerItem]. + * - `keys` is regenerated from the items so the two stay consistent. + * - rowCount/columnCount are derived from rowUnitCount/columnUnitCount + * (rounded up) so legacy code that still reads them keeps working. + * - KeyData.row / KeyData.column carry approximate (rowUnits/2, + * columnUnits/2) values for backward compatibility, but they are + * *not* the source of truth — the layout's `items` are. + */ + private fun buildAlphabetTemplate(spec: TemplateLayoutSpec): KeyboardLayout { + val items = mutableListOf() + val keys = mutableListOf() + + spec.rows.forEach { row -> + var cursor = row.startColumnUnits + val approxRow = row.rowUnits / 2 + + row.items.forEachIndexed { itemIndex, itemSpec -> + val placement = GridPlacement( + rowUnits = row.rowUnits, + columnUnits = cursor, + rowSpanUnits = row.heightUnits, + columnSpanUnits = itemSpec.columnSpanUnits + ) + val approxCol = cursor / 2 + val approxColSpan = (itemSpec.columnSpanUnits + 1) / 2 + val approxRowSpan = (row.heightUnits + 1) / 2 + + when (itemSpec) { + is CharKeySpec -> { + val keyId = "${spec.idPrefix}_key_${safeKeyIdSuffix(itemSpec.label)}" + val keyData = KeyData( + label = itemSpec.label, + row = approxRow, + column = approxCol, + isFlickable = false, + action = KeyAction.Text(itemSpec.label), + rowSpan = approxRowSpan, + colSpan = approxColSpan, + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyId = keyId + ) + items += KeyItem(id = keyId, keyData = keyData, placement = placement) + keys += keyData + } + + is SpecialKeySpec -> { + val keyId = "${spec.idPrefix}_${itemSpec.idSuffix}" + val keyData = KeyData( + label = itemSpec.label, + row = approxRow, + column = approxCol, + isFlickable = false, + action = itemSpec.action, + rowSpan = approxRowSpan, + colSpan = approxColSpan, + keyType = KeyType.NORMAL, + isSpecialKey = true, + drawableResId = itemSpec.drawableResId, + keyId = keyId + ) + items += KeyItem(id = keyId, keyData = keyData, placement = placement) + keys += keyData + } + + is SpacerSpec -> { + val spacerId = + "${spec.idPrefix}_row_${row.rowUnits}_${itemSpec.idSuffix}_${itemIndex}" + items += SpacerItem(id = spacerId, placement = placement) + } + } + + cursor += itemSpec.columnSpanUnits + } + } + + val derivedColumnCount = (spec.columnUnitCount + 1) / 2 + val derivedRowCount = (spec.rowUnitCount + 1) / 2 + return KeyboardLayout( + keys = keys, + flickKeyMaps = emptyMap(), + columnCount = derivedColumnCount, + rowCount = derivedRowCount, + items = items, + columnUnitCount = spec.columnUnitCount, + rowUnitCount = spec.rowUnitCount + ) + } + + private fun chars(vararg s: String): List = s.map { CharKeySpec(it) } + + private val shiftSpec + get() = SpecialKeySpec( + idSuffix = "shift", + action = KeyAction.ShiftKey, + drawableResId = com.kazumaproject.core.R.drawable.shift_24px, + columnSpanUnits = 2 + ) + + private val deleteSpec + get() = SpecialKeySpec( + idSuffix = "delete", + action = KeyAction.Delete, + drawableResId = com.kazumaproject.core.R.drawable.backspace_24px, + columnSpanUnits = 2 + ) + + private val switchImeSpec + get() = SpecialKeySpec( + idSuffix = "switch_next_ime", + action = KeyAction.SwitchToNextIme, + drawableResId = com.kazumaproject.core.R.drawable.language_24dp, + columnSpanUnits = 2 + ) + + private fun spaceSpec(span: Int) = SpecialKeySpec( + idSuffix = "space", + action = KeyAction.Space, + drawableResId = com.kazumaproject.core.R.drawable.baseline_space_bar_24, + columnSpanUnits = span + ) + + private fun enterSpec(span: Int) = SpecialKeySpec( + idSuffix = "enter", + action = KeyAction.Enter, + drawableResId = com.kazumaproject.core.R.drawable.baseline_keyboard_return_24, + columnSpanUnits = span + ) + + /** + * 英字 QWERTY 配列テンプレート。 + * Row 0: q w e r t y u i o p + * Row 1: a s d f g h j k l + * Row 2: Shift | spacer | z x c v b n m | spacer | Delete + * Row 3: SwitchIme | Space | Enter + */ + fun createQwertyTemplateLayout(): KeyboardLayout { + val spec = TemplateLayoutSpec( + idPrefix = "qwerty", + name = "QWERTY", + rowUnitCount = 8, + columnUnitCount = 20, + rows = listOf( + TemplateRowSpec( + rowUnits = 0, + items = listOf( + *chars("q", "w", "e", "r", "t", "y", "u", "i", "o", "p").toTypedArray() + ) + ), + TemplateRowSpec( + rowUnits = 2, + items = listOf( + SpacerSpec("row1_gap", columnSpanUnits = 1), + *chars("a", "s", "d", "f", "g", "h", "j", "k", "l").toTypedArray() + ) + ), + TemplateRowSpec( + rowUnits = 4, + items = listOf( + shiftSpec, + SpacerSpec("shift_gap", columnSpanUnits = 1), + *chars("z", "x", "c", "v", "b", "n", "m").toTypedArray(), + SpacerSpec("delete_gap", columnSpanUnits = 1), + deleteSpec + ) + ), + TemplateRowSpec( + rowUnits = 6, + items = listOf( + switchImeSpec, + spaceSpec(span = 14), + enterSpec(span = 4) + ) + ) + ) + ) + return buildAlphabetTemplate(spec) + } + private fun createHiraganaToggleLayout( inputStyle: String, deleteKeyFlickSettings: DeleteKeyFlickSettings ): KeyboardLayout { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index 04e049d6a..04d175553 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -21,6 +21,7 @@ import android.view.View import android.view.ViewConfiguration import android.widget.Button import android.widget.GridLayout +import android.widget.Space import androidx.annotation.AttrRes import androidx.annotation.DrawableRes import androidx.appcompat.widget.AppCompatImageButton @@ -42,10 +43,13 @@ import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme +import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.SpacerItem import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges import com.kazumaproject.custom_keyboard.data.toCircularFlickKeyMaps import com.kazumaproject.custom_keyboard.data.toLegacyFlickDirection @@ -305,21 +309,59 @@ class FlickKeyboardView @JvmOverloads constructor( dynamicKeyMap.clear() currentLayout = layout - columnCount = layout.columnCount - rowCount = layout.rowCount + columnCount = if (layout.items.isNotEmpty()) layout.columnUnitCount else layout.columnCount + rowCount = if (layout.items.isNotEmpty()) layout.rowUnitCount else layout.rowCount isFocusable = false - layout.keys.forEach { keyData -> - val index = childCount - val keyView = createKeyView(keyData) - val controller = attachKeyBehavior(keyView, keyData) - - keyData.keyId?.let { id -> - dynamicKeyMap[id] = KeyInfo(keyView, keyData, controller, index) + if (layout.items.isNotEmpty()) { + layout.items.forEach { item -> + when (item) { + is KeyItem -> addKeyItem(item) + is SpacerItem -> addSpacerItem(item) + } } + } else { + layout.keys.forEach { keyData -> + addKeyItem( + KeyItem( + id = keyData.keyId + ?: "legacy_${keyData.row}_${keyData.column}_${keyData.label}", + keyData = keyData, + placement = GridPlacement( + rowUnits = keyData.row, + columnUnits = keyData.column, + rowSpanUnits = keyData.rowSpan, + columnSpanUnits = keyData.colSpan + ) + ) + ) + } + } + } - addView(keyView) + private fun addKeyItem(item: KeyItem) { + val keyData = item.keyData + val index = childCount + val keyView = createKeyView(keyData) + keyView.layoutParams = createLayoutParams(item.placement, keyData) + val controller = attachKeyBehavior(keyView, keyData) + + keyData.keyId?.let { id -> + dynamicKeyMap[id] = KeyInfo(keyView, keyData, controller, index) + } + + addView(keyView) + } + + private fun addSpacerItem(item: SpacerItem) { + val spacer = Space(context).apply { + isClickable = false + isFocusable = false + isEnabled = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } + spacer.layoutParams = createLayoutParams(item.placement) + addView(spacer) } fun updateDynamicKey(keyId: String, stateIndex: Int) { @@ -397,6 +439,51 @@ class FlickKeyboardView @JvmOverloads constructor( return dpToPx(marginDp.roundToInt()) } + private fun createLayoutParams( + placement: GridPlacement, + keyData: KeyData? = null + ): LayoutParams { + return LayoutParams().apply { + rowSpec = spec( + placement.rowUnits, + placement.rowSpanUnits, + FILL, + placement.rowSpanUnits.toFloat() + ) + columnSpec = spec( + placement.columnUnits, + placement.columnSpanUnits, + FILL, + placement.columnSpanUnits.toFloat() + ) + width = 0 + height = 0 + + if (keyData != null) { + val baseHorizontalMarginDp: Int + val baseVerticalMarginDp: Int + + if (keyData.keyType == KeyType.STANDARD_FLICK) { + baseHorizontalMarginDp = 6 + baseVerticalMarginDp = 9 + } else if (keyData.isSpecialKey) { + baseHorizontalMarginDp = 3 + baseVerticalMarginDp = 6 + } else { + baseHorizontalMarginDp = 4 + baseVerticalMarginDp = 6 + } + + setMargins( + getScaledHorizontalMarginPx(baseHorizontalMarginDp), + getScaledVerticalMarginPx(baseVerticalMarginDp), + getScaledHorizontalMarginPx(baseHorizontalMarginDp), + getScaledVerticalMarginPx(baseVerticalMarginDp) + ) + } + } + } + private fun getSpecialKeyTextSizeSp(): Float { return specialKeyTextSizeSp.coerceIn(8f, 32f) } @@ -557,8 +644,8 @@ class FlickKeyboardView @JvmOverloads constructor( private fun isCircularMapSwitchAction(action: FlickAction?): Boolean { return action is FlickAction.Action && - action.action == KeyAction.MoveCustomKeyboardTab && - action.label == "⇄" + action.action == KeyAction.MoveCustomKeyboardTab && + action.label == "⇄" } private fun getGuideLabels(stringMap: Map): AutoSizeButton.FlickGuideLabels { @@ -860,35 +947,6 @@ class FlickKeyboardView @JvmOverloads constructor( } } - val baseHorizontalMarginDp: Int - val baseVerticalMarginDp: Int - - if (keyData.keyType == KeyType.STANDARD_FLICK) { - baseHorizontalMarginDp = 6 - baseVerticalMarginDp = 9 - } else if (keyData.isSpecialKey) { - baseHorizontalMarginDp = 3 - baseVerticalMarginDp = 6 - } else { - baseHorizontalMarginDp = 4 - baseVerticalMarginDp = 6 - } - - val params = LayoutParams().apply { - rowSpec = spec(keyData.row, keyData.rowSpan, FILL, 1f) - columnSpec = spec(keyData.column, keyData.colSpan, FILL, 1f) - width = 0 - height = 0 - - setMargins( - getScaledHorizontalMarginPx(baseHorizontalMarginDp), - getScaledVerticalMarginPx(baseVerticalMarginDp), - getScaledHorizontalMarginPx(baseHorizontalMarginDp), - getScaledVerticalMarginPx(baseVerticalMarginDp) - ) - } - - keyView.layoutParams = params return keyView } @@ -948,9 +1006,9 @@ class FlickKeyboardView @JvmOverloads constructor( keyData.keyId?.let { layout.circularFlickKeyMaps[it] } ?: layout.circularFlickKeyMaps[keyData.label] ?: ( - keyData.keyId?.let { layout.flickKeyMaps[it] } - ?: layout.flickKeyMaps[keyData.label] - )?.let { mapOf(keyData.label to it).toCircularFlickKeyMaps()[keyData.label] } + keyData.keyId?.let { layout.flickKeyMaps[it] } + ?: layout.flickKeyMaps[keyData.label] + )?.let { mapOf(keyData.label to it).toCircularFlickKeyMaps()[keyData.label] } Log.d("FlickKeyboardView KeyType.CIRCULAR_FLICK", "$circularKeyMapsList") if (!circularKeyMapsList.isNullOrEmpty()) { val controller = CustomAngleFlickController(context, flickSensitivity).apply { @@ -1021,7 +1079,10 @@ class FlickKeyboardView @JvmOverloads constructor( override fun onPress(action: FlickAction?) { when (action) { is FlickAction.Input -> notifyTextPress(action.char) - is FlickAction.Action -> this@FlickKeyboardView.listener?.onPress(action.action) + is FlickAction.Action -> this@FlickKeyboardView.listener?.onPress( + action.action + ) + null -> Unit } } @@ -1133,7 +1194,10 @@ class FlickKeyboardView @JvmOverloads constructor( } } - override fun onFlickUpAfterLongPress(action: KeyAction, isFlick: Boolean) { + override fun onFlickUpAfterLongPress( + action: KeyAction, + isFlick: Boolean + ) { if (action !is KeyAction.Text) { this@FlickKeyboardView.listener?.onFlickActionUpAfterLongPress( action, isFlick = isFlick @@ -1147,17 +1211,31 @@ class FlickKeyboardView @JvmOverloads constructor( when (themeMode) { "custom" -> { - controller.setPopupColors(FlickPopupColorTheme( - segmentColor = customSpecialKeyColor, - segmentHighlightGradientStartColor = manipulateColor(customSpecialKeyColor, 1.2f), - segmentHighlightGradientEndColor = manipulateColor(customSpecialKeyColor, 1.2f), - centerGradientStartColor = customSpecialKeyColor, - centerGradientEndColor = customSpecialKeyColor, - centerHighlightGradientStartColor = manipulateColor(customSpecialKeyColor, 1.2f), - centerHighlightGradientEndColor = manipulateColor(customSpecialKeyColor, 1.2f), - separatorColor = customSpecialKeyTextColor, - textColor = customSpecialKeyTextColor - )) + controller.setPopupColors( + FlickPopupColorTheme( + segmentColor = customSpecialKeyColor, + segmentHighlightGradientStartColor = manipulateColor( + customSpecialKeyColor, + 1.2f + ), + segmentHighlightGradientEndColor = manipulateColor( + customSpecialKeyColor, + 1.2f + ), + centerGradientStartColor = customSpecialKeyColor, + centerGradientEndColor = customSpecialKeyColor, + centerHighlightGradientStartColor = manipulateColor( + customSpecialKeyColor, + 1.2f + ), + centerHighlightGradientEndColor = manipulateColor( + customSpecialKeyColor, + 1.2f + ), + separatorColor = customSpecialKeyTextColor, + textColor = customSpecialKeyTextColor + ) + ) } } @@ -1450,7 +1528,10 @@ class FlickKeyboardView @JvmOverloads constructor( } } - override fun onFlickUpAfterLongPress(action: KeyAction, isFlick: Boolean) { + override fun onFlickUpAfterLongPress( + action: KeyAction, + isFlick: Boolean + ) { if (action !is KeyAction.Text) { this@FlickKeyboardView.listener?.onFlickActionUpAfterLongPress( action, @@ -1872,11 +1953,22 @@ class FlickKeyboardView @JvmOverloads constructor( } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val actionToDispatch = + if (event.actionMasked == MotionEvent.ACTION_UP) { + MotionEvent.ACTION_UP + } else { + MotionEvent.ACTION_CANCEL + } + + dispatchEndEventToTrackedTargets(event, actionToDispatch) + setCursorMode(false) crossFlickControllers.forEach { it.dismissAllPopups() } clearSpaceKeyPressedState() + motionTargets.clear() pointerDownTime.clear() + return true } } @@ -2137,21 +2229,48 @@ class FlickKeyboardView @JvmOverloads constructor( return (dp * resources.displayMetrics.density).toInt() } - private fun clearSpaceKeyPressedState() { - for (i in 0 until childCount) { - val child = getChildAt(i) - - val isKuhakuKey = when (child) { - is AutoSizeButton -> child.text?.toString() == "空白" - is AppCompatImageButton -> child.contentDescription?.toString() == "空白" - else -> false + private fun dispatchEndEventToTrackedTargets( + sourceEvent: MotionEvent, + actionToDispatch: Int + ) { + motionTargets.forEach { (trackedPointerId, target) -> + val trackedPointerIndex = sourceEvent.findPointerIndex(trackedPointerId) + if (trackedPointerIndex == -1) { + target.isPressed = false + target.isSelected = false + target.refreshDrawableState() + return@forEach } - if (isKuhakuKey) { - child.isPressed = false - child.isSelected = false - child.refreshDrawableState() - } + val downTime = pointerDownTime[trackedPointerId] ?: sourceEvent.downTime + val x = sourceEvent.getX(trackedPointerIndex) + val y = sourceEvent.getY(trackedPointerIndex) + + val endEvent = MotionEvent.obtain( + downTime, + sourceEvent.eventTime, + actionToDispatch, + x, + y, + sourceEvent.metaState + ) + + endEvent.offsetLocation(-target.left.toFloat(), -target.top.toFloat()) + target.dispatchTouchEvent(endEvent) + endEvent.recycle() } } + + private fun clearSpaceKeyPressedState() { + dynamicKeyMap.values + .filter { keyInfo -> + keyInfo.keyData.action == KeyAction.Space + } + .forEach { keyInfo -> + keyInfo.view.isPressed = false + keyInfo.view.isSelected = false + keyInfo.view.refreshDrawableState() + } + } + } diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyActionMapperTextTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyActionMapperTextTest.kt new file mode 100644 index 000000000..624bca78c --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyActionMapperTextTest.kt @@ -0,0 +1,25 @@ +package com.kazumaproject.custom_keyboard.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class KeyActionMapperTextTest { + + @Test + fun fromKeyAction_savesTextAction() { + assertEquals("Text:q", KeyActionMapper.fromKeyAction(KeyAction.Text("q"))) + } + + @Test + fun toKeyAction_restoresTextAction() { + assertEquals(KeyAction.Text("q"), KeyActionMapper.toKeyAction("Text:q")) + } + + @Test + fun inputText_roundTripsSeparatelyFromText() { + val saved = KeyActionMapper.fromKeyAction(KeyAction.InputText("hello")) + + assertEquals("InputText:hello", saved) + assertEquals(KeyAction.InputText("hello"), KeyActionMapper.toKeyAction(saved)) + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt new file mode 100644 index 000000000..402063a92 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt @@ -0,0 +1,405 @@ +package com.kazumaproject.custom_keyboard.layout + +import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyItem +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.data.SpacerItem +import com.kazumaproject.custom_keyboard.data.copyWithItems +import com.kazumaproject.custom_keyboard.data.halfColumnSpacer +import com.kazumaproject.custom_keyboard.data.halfRowSpacer +import com.kazumaproject.custom_keyboard.data.hasPlacementIssues +import com.kazumaproject.custom_keyboard.data.isPlacementOverlapping +import com.kazumaproject.custom_keyboard.data.oneColumnSpacer +import com.kazumaproject.custom_keyboard.data.oneRowSpacer +import com.kazumaproject.custom_keyboard.data.swapKeyPlacements +import com.kazumaproject.custom_keyboard.data.toKeyItem +import com.kazumaproject.custom_keyboard.data.usesFlexiblePlacement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * QWERTY / AZERTY / Dvorak / Colemak テンプレートの基本仕様テスト。 + */ +class AlphabetTemplateLayoutsTest { + + private fun assertCommonStructure( + layout: KeyboardLayout, + expectedColumnUnitCount: Int = 20, + expectedColumnCount: Int = 10 + ) { + assertEquals("columnCount", expectedColumnCount, layout.columnCount) + assertEquals("rowCount must be 4", 4, layout.rowCount) + assertEquals("columnUnitCount", expectedColumnUnitCount, layout.columnUnitCount) + assertEquals("rowUnitCount must be 8", 8, layout.rowUnitCount) + assertFalse("isRomaji should default to false", layout.isRomaji) + assertFalse("isDirectMode should default to false", layout.isDirectMode) + assertTrue("flickKeyMaps should be empty", layout.flickKeyMaps.isEmpty()) + } + + private fun row0Labels(layout: KeyboardLayout): List = + // Source of truth is items + GridPlacement, so order by columnUnits. + layout.items + .filterIsInstance() + .filter { it.placement.rowUnits == 0 && !it.keyData.isSpecialKey } + .sortedBy { it.placement.columnUnits } + .map { it.keyData.label } + + /** + * Verify Shift / Delete are now on Row 2 (rowUnits = 4) with at least + * one Spacer separating them from the character keys, and that Row 3 + * holds SwitchToNextIme + Space + Enter. + */ + private fun assertHasSpecialKeys(layout: KeyboardLayout, prefix: String) { + fun specialItem(action: KeyAction): KeyItem? = + layout.items.filterIsInstance() + .firstOrNull { it.keyData.action == action && it.keyData.isSpecialKey } + + val space = specialItem(KeyAction.Space) + val enter = specialItem(KeyAction.Enter) + val delete = specialItem(KeyAction.Delete) + val shift = specialItem(KeyAction.ShiftKey) + val switchIme = specialItem(KeyAction.SwitchToNextIme) + + assertNotNull("$prefix should have Space", space) + assertNotNull("$prefix should have Enter", enter) + assertNotNull("$prefix should have Delete", delete) + assertNotNull("$prefix should have ShiftKey", shift) + assertNotNull("$prefix should have SwitchToNextIme", switchIme) + + // Shift / Delete moved to Row 2 (rowUnits = 4) + assertEquals("$prefix Shift rowUnits", 4, shift!!.placement.rowUnits) + assertEquals("$prefix Delete rowUnits", 4, delete!!.placement.rowUnits) + + // Row 3 (rowUnits = 6): SwitchIme | Space | Enter + assertEquals("$prefix SwitchToNextIme rowUnits", 6, switchIme!!.placement.rowUnits) + assertEquals("$prefix Space rowUnits", 6, space!!.placement.rowUnits) + assertEquals("$prefix Enter rowUnits", 6, enter!!.placement.rowUnits) + } + + private fun assertAllCharacterKeysAreNormalText(layout: KeyboardLayout) { + layout.keys.filter { !it.isSpecialKey }.forEach { key -> + val action = key.action + assertTrue( + "Character key '${key.label}' must use KeyAction.Text", + action is KeyAction.Text && action.text == key.label + ) + assertEquals( + "Character key '${key.label}' must be NORMAL", + KeyType.NORMAL, key.keyType + ) + assertFalse("Character key '${key.label}' must not be flickable", key.isFlickable) + assertNotNull("Character key '${key.label}' must have a keyId", key.keyId) + } + } + + private fun keyItem(layout: KeyboardLayout, keyId: String): KeyItem = + layout.items.filterIsInstance().first { it.keyData.keyId == keyId } + + @Test + fun qwertyTemplate_hasExpectedRow0AndStructure() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + assertCommonStructure(layout) + assertEquals( + listOf("q", "w", "e", "r", "t", "y", "u", "i", "o", "p"), + row0Labels(layout) + ) + assertHasSpecialKeys(layout, "QWERTY") + assertAllCharacterKeysAreNormalText(layout) + assertNotNull(layout.keys.firstOrNull { it.keyId == "qwerty_key_q" }) + assertNotNull(layout.keys.firstOrNull { it.keyId == "qwerty_key_p" }) + } + + @Test + fun qwertyTemplate_usesHalfUnitPlacements() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + + assertEquals(20, layout.columnUnitCount) + assertEquals(8, layout.rowUnitCount) + + val q = keyItem(layout, "qwerty_key_q") + assertEquals(0, q.placement.rowUnits) + assertEquals(0, q.placement.columnUnits) + assertEquals(2, q.placement.rowSpanUnits) + assertEquals(2, q.placement.columnSpanUnits) + + // Row1 has half-cell offset (startColumnUnits = 1) + val a = keyItem(layout, "qwerty_key_a") + assertEquals(2, a.placement.rowUnits) + assertEquals(1, a.placement.columnUnits) + + // Row2 design: Shift(2) | spacer(1) | z..m | spacer(1) | Delete(2) + val shift = keyItem(layout, "qwerty_shift") + assertEquals(4, shift.placement.rowUnits) + assertEquals(0, shift.placement.columnUnits) + assertEquals(2, shift.placement.columnSpanUnits) + + val z = keyItem(layout, "qwerty_key_z") + assertEquals(4, z.placement.rowUnits) + // After Shift(2) + spacer(1) = 3 columnUnits + assertEquals(3, z.placement.columnUnits) + + val delete = keyItem(layout, "qwerty_delete") + assertEquals(4, delete.placement.rowUnits) + // 2 (shift) + 1 (spacer) + 7*2 (letters z..m) + 1 (spacer) = 18 + assertEquals(18, delete.placement.columnUnits) + assertEquals(2, delete.placement.columnSpanUnits) + + val space = keyItem(layout, "qwerty_space") + assertEquals(6, space.placement.rowUnits) + assertEquals(2, space.placement.columnUnits) + assertEquals(14, space.placement.columnSpanUnits) + } + + @Test + fun spacerHelpers_createExpectedUnitSpacers() { + val halfColumn = halfColumnSpacer("half_column", rowUnits = 0, columnUnits = 0) + assertEquals(2, halfColumn.placement.rowSpanUnits) + assertEquals(1, halfColumn.placement.columnSpanUnits) + + val oneColumn = oneColumnSpacer("one_column", rowUnits = 0, columnUnits = 0) + assertEquals(2, oneColumn.placement.rowSpanUnits) + assertEquals(2, oneColumn.placement.columnSpanUnits) + + val halfRow = halfRowSpacer( + id = "half_row", + rowUnits = 0, + columnUnits = 0, + columnSpanUnits = 20 + ) + assertEquals(1, halfRow.placement.rowSpanUnits) + assertEquals(20, halfRow.placement.columnSpanUnits) + + val oneRow = oneRowSpacer( + id = "one_row", + rowUnits = 0, + columnUnits = 0, + columnSpanUnits = 20 + ) + assertEquals(2, oneRow.placement.rowSpanUnits) + assertEquals(20, oneRow.placement.columnSpanUnits) + } + + @Test + fun spacerItems_doNotBecomeKeys() { + val key = KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + keyId = "key_a" + ) + val spacer = halfColumnSpacer("spacer", rowUnits = 0, columnUnits = 2) + val layout = KeyboardLayout( + keys = listOf(key), + flickKeyMaps = emptyMap(), + columnCount = 2, + rowCount = 1, + items = listOf(KeyItem("key_a", key, key.toKeyItem().placement), spacer), + columnUnitCount = 4, + rowUnitCount = 2 + ) + + assertEquals(1, layout.keys.size) + assertTrue(layout.items.any { it is SpacerItem }) + assertFalse(layout.keys.any { it.keyId == spacer.id }) + } + + // ==================================================================== + // New tests for Spec-based templates: Row2 layout, placement validity, + // swap, and items-based source-of-truth helpers. + // ==================================================================== + + @Test + fun qwertyTemplate_row2_hasShiftSpacerLettersSpacerDelete() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + val row2Items = layout.items + .filter { it.placement.rowUnits == 4 } + .sortedBy { it.placement.columnUnits } + + // Order: Shift, Spacer, z, x, c, v, b, n, m, Spacer, Delete (11 entries) + assertEquals(11, row2Items.size) + assertTrue( + "First Row2 item must be Shift", + (row2Items[0] as? KeyItem)?.keyData?.action == KeyAction.ShiftKey + ) + assertTrue("Second Row2 item must be a SpacerItem", row2Items[1] is SpacerItem) + + val letters = row2Items.subList(2, 9).mapNotNull { (it as? KeyItem)?.keyData?.label } + assertEquals(listOf("z", "x", "c", "v", "b", "n", "m"), letters) + + assertTrue("Tenth Row2 item must be a SpacerItem", row2Items[9] is SpacerItem) + assertTrue( + "Last Row2 item must be Delete", + (row2Items[10] as? KeyItem)?.keyData?.action == KeyAction.Delete + ) + } + + @Test + fun qwertyTemplate_row2_placementsDoNotOverlap() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + val row2 = layout.items.filter { it.placement.rowUnits == 4 } + + // Within Row2: no two non-spacer-spacer items overlap. + for (i in row2.indices) { + for (j in i + 1 until row2.size) { + val a = row2[i] + val b = row2[j] + if (a is SpacerItem && b is SpacerItem) continue + assertFalse( + "$i and $j overlap (${a.placement} vs ${b.placement})", + isPlacementOverlapping(a.placement, b.placement) + ) + } + } + } + + @Test + fun qwertyTemplate_layoutHasNoPlacementIssues() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + assertFalse( + hasPlacementIssues( + items = layout.items, + rowUnitCount = layout.rowUnitCount, + columnUnitCount = layout.columnUnitCount + ) + ) + } + + @Test + fun qwertyTemplate_usesFlexiblePlacement() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + assertTrue(layout.usesFlexiblePlacement()) + } + + @Test + fun swapKeyPlacements_swapsPlacementsOnly_andKeepsSpacers() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + + val qBefore = keyItem(layout, "qwerty_key_q") + val pBefore = keyItem(layout, "qwerty_key_p") + val spacerCountBefore = layout.items.count { it is SpacerItem } + + val swapped = layout.swapKeyPlacements("qwerty_key_q", "qwerty_key_p") + + val qAfter = keyItem(swapped, "qwerty_key_q") + val pAfter = keyItem(swapped, "qwerty_key_p") + + // Placements are swapped + assertEquals(pBefore.placement, qAfter.placement) + assertEquals(qBefore.placement, pAfter.placement) + + // KeyData identity (label / action / keyId) is preserved + assertEquals("q", qAfter.keyData.label) + assertEquals("p", pAfter.keyData.label) + assertEquals(KeyAction.Text("q"), qAfter.keyData.action) + assertEquals(KeyAction.Text("p"), pAfter.keyData.action) + + // SpacerItems survived the swap + assertEquals(spacerCountBefore, swapped.items.count { it is SpacerItem }) + assertTrue(spacerCountBefore > 0) + } + + @Test + fun swapKeyPlacements_swapPreservesHalfCellOffset() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + + // Row 1 keys carry a half-cell offset (columnUnits = 1, 3, 5, ...) + val a = keyItem(layout, "qwerty_key_a") + assertEquals(1, a.placement.columnUnits) + + // Swap 'a' (Row1, half-cell) with 's' (Row1, also half-cell) + val swapped = layout.swapKeyPlacements("qwerty_key_a", "qwerty_key_s") + + val aAfter = keyItem(swapped, "qwerty_key_a") + // 's' was at columnUnits = 3 (half-cell offset), so 'a' must inherit it. + assertEquals(3, aAfter.placement.columnUnits) + + // The bug being protected against: the legacy swap rewrote + // KeyData.row/column → integer cells, which collapsed columnUnits 3 + // back to 2 (= column 1 * 2). Make sure that does NOT happen. + assertTrue( + "Half-cell offset must survive the swap", + aAfter.placement.columnUnits % 2 == 1 + ) + } + + @Test + fun hasPlacementIssues_detectsKeyKeyOverlap() { + val keyA = KeyData( + label = "a", row = 0, column = 0, isFlickable = false, + action = KeyAction.Text("a"), keyId = "a" + ) + val keyB = KeyData( + label = "b", row = 0, column = 0, isFlickable = false, + action = KeyAction.Text("b"), keyId = "b" + ) + val items = listOf( + KeyItem("a", keyA, GridPlacement(rowUnits = 0, columnUnits = 0)), + KeyItem("b", keyB, GridPlacement(rowUnits = 0, columnUnits = 1)) + ) + assertTrue( + hasPlacementIssues(items, rowUnitCount = 4, columnUnitCount = 8) + ) + } + + @Test + fun hasPlacementIssues_acceptsAdjacentNonOverlappingKeys() { + val keyA = KeyData( + label = "a", row = 0, column = 0, isFlickable = false, + action = KeyAction.Text("a"), keyId = "a" + ) + val keyB = KeyData( + label = "b", row = 0, column = 0, isFlickable = false, + action = KeyAction.Text("b"), keyId = "b" + ) + // Adjacent at columnUnits 0..2 and 2..4 — touching, not overlapping. + val items = listOf( + KeyItem("a", keyA, GridPlacement(rowUnits = 0, columnUnits = 0)), + KeyItem("b", keyB, GridPlacement(rowUnits = 0, columnUnits = 2)) + ) + assertFalse( + hasPlacementIssues(items, rowUnitCount = 4, columnUnitCount = 8) + ) + } + + @Test + fun hasPlacementIssues_detectsSpacerOverlap() { + val key = KeyData( + label = "a", row = 0, column = 0, isFlickable = false, + action = KeyAction.Text("a"), keyId = "a" + ) + val items = listOf( + KeyItem("a", key, GridPlacement(rowUnits = 0, columnUnits = 0)), + SpacerItem("spacer", GridPlacement(rowUnits = 0, columnUnits = 1)) + ) + assertTrue( + hasPlacementIssues(items, rowUnitCount = 4, columnUnitCount = 8) + ) + } + + @Test + fun copyWithItems_keepsKeysAndItemsConsistent() { + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + val keyItems = layout.items.filterIsInstance() + + // Shuffle items: reverse order to confirm both lists are derived from items. + val newItems = layout.items.reversed() + val updated = layout.copyWithItems(newItems) + + // Items reflect the new order + assertEquals(newItems, updated.items) + + // Keys are derived from items (filtered to KeyItems) — count equals + // KeyItem count and labels match. + val updatedKeyItems = updated.items.filterIsInstance() + assertEquals(keyItems.size, updated.keys.size) + assertEquals(updatedKeyItems.map { it.keyData.label }, updated.keys.map { it.label }) + } +}