diff --git a/app/build.gradle b/app/build.gradle index dc0dfe93..4c3b5219 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 779 - versionName "1.7.86" + versionCode 780 + versionName "1.7.87" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt index dee13587..0488fd2a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt @@ -21,7 +21,8 @@ data class CandidateStripPresentation( val resetCandidateTabSelection: Boolean, val showIndependentShortcutToolbar: Boolean, val reserveIndependentShortcutToolbarSpace: Boolean, - val showIntegratedShortcut: Boolean + val showIntegratedShortcutItems: Boolean, + val showIntegratedShortcutEntry: Boolean ) object CandidateStripPresentationPolicy { @@ -49,8 +50,10 @@ object CandidateStripPresentationPolicy { shortcutPresentation.showIndependentToolbar && !hideShortcutForCandidates, reserveIndependentShortcutToolbarSpace = shortcutPresentation.showIndependentToolbar && hideShortcutForCandidates, - showIntegratedShortcut = - shortcutPresentation.showIntegratedShortcuts && !hideShortcutForCandidates + showIntegratedShortcutItems = + shortcutPresentation.showIntegratedShortcutItems && !hideShortcutForCandidates, + showIntegratedShortcutEntry = + shortcutPresentation.showIntegratedShortcutEntry && !hideShortcutForCandidates ) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index f0edff91..651aa508 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -649,6 +649,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun setSuggestionAdapterSuggestionsOnMain(candidates: List) { runOnMainThread { measureDebugSection("IMEService.setSuggestionAdapterSuggestionsOnMain") { + collapseShortcutEntryExpansion() suggestionAdapter?.suggestions = candidates } } @@ -660,6 +661,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) { runOnMainThread { measureDebugSection("IMEService.setSuggestionAdaptersOnMain") { + collapseShortcutEntryExpansion() suggestionAdapter?.suggestions = candidates suggestionAdapterFull?.suggestions = fullCandidates } @@ -673,6 +675,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) = measureDebugStage("IMEService.updateSuggestionAdaptersOnMain") { withContext(Dispatchers.Main.immediate) { if (!shouldApplyCandidateResult(insertString)) return@withContext + collapseShortcutEntryExpansion() suggestionAdapter?.suggestions = candidates suggestionAdapterFull?.suggestions = fullCandidates } @@ -1035,6 +1038,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private val _ngPattern = MutableStateFlow("".toRegex()) private val ngPattern: StateFlow = _ngPattern private var isPrivateMode = false + private var incognitoModeDetectionPreference: Boolean = true + private var showLearnedCandidatesInIncognitoPreference: Boolean = true private val _keyboardFloatingMode = MutableStateFlow(false) private val keyboardFloatingMode = _keyboardFloatingMode.asStateFlow() @@ -1447,6 +1452,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } } + onStartAnchoredContentCommitted = { + if (Looper.myLooper() == Looper.getMainLooper()) { + anchorActiveSuggestionStripStartForLeadingContent() + } else { + mainHandler.post { + anchorActiveSuggestionStripStartForLeadingContent() + } + } + } } suggestionAdapterFull = SuggestionAdapter() shortcutAdapter = ShortcutAdapter() @@ -1660,6 +1674,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, isOmissionSearchEnable = preferences.isOmissionSearchEnable delayTime = preferences.delayTime isLearnDictionaryMode = preferences.isLearnDictionaryMode + incognitoModeDetectionPreference = preferences.incognitoModeDetectionPreference + showLearnedCandidatesInIncognitoPreference = + preferences.showLearnedCandidatesInIncognitoPreference isUserDictionaryEnable = preferences.isUserDictionaryEnable isUserTemplateEnable = preferences.isUserTemplateEnable hankakuPreference = preferences.hankakuPreference @@ -1961,6 +1978,26 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun updateIncognitoModeState(editorInfo: EditorInfo?) { + val detected = incognitoModeDetectionPreference && + editorInfo != null && + (editorInfo.imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) != 0 + isPrivateMode = detected + suggestionAdapter?.setIncognitoIcon( + if (detected) { + ContextCompat.getDrawable(this, com.kazumaproject.core.R.drawable.incognito) + } else { + null + } + ) + } + + private fun learnedRepositoryForSuggestion(): LearnRepository? { + if (isLearnDictionaryMode != true) return null + if (isPrivateMode && !showLearnedCandidatesInIncognitoPreference) return null + return learnRepository + } + private fun applyDictionaryOverrideRevisionIfNeeded() { val currentRevision = dictionaryOverrideStore.currentRevision if (currentRevision == lastAppliedDictionaryOverrideRevision) return @@ -3285,6 +3322,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean) { super.onStartInputView(editorInfo, restarting) isInputViewActive = true + collapseShortcutEntryExpansion() shortcutInputBehaviorOverride = null keyboardSelectionPopupWindow?.dismiss() addUserDictionaryPopup?.dismiss() @@ -3623,17 +3661,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } setMainSuggestionColumn(mainView) } - editorInfo?.let { info -> - if ((info.imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) != 0) { - isPrivateMode = true - suggestionAdapter?.setIncognitoIcon( - ContextCompat.getDrawable(this, com.kazumaproject.core.R.drawable.incognito) - ) - } else { - isPrivateMode = false - suggestionAdapter?.setIncognitoIcon(null) - } - } + updateIncognitoModeState(editorInfo) + anchorActiveSuggestionStripStartIfLeadingContentExpected() if (hasPhysicalKeyboard) { floatingDockView.setText("あ") ensurePhysicalKeyboardPopupWindows() @@ -3654,6 +3683,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, releaseKeyboardBackgroundVideoPlayer() releaseFloatingKeyboardBackgroundVideoPlayer() stopVoiceInput() + collapseShortcutEntryExpansion() floatingCandidateWindow?.dismiss() floatingDockWindow?.dismiss() floatingModeSwitchWindow?.dismiss() @@ -3673,6 +3703,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, Timber.d("onUpdate onDestroy") stopAllOngoingKeyLongPresses() disableKeyboardLayoutEditMode(updateSurface = false) + collapseShortcutEntryExpansion() isInputViewActive = false releaseSuminagashiInkEffects() releaseKeyboardBackgroundVideoPlayer() @@ -3716,6 +3747,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, isOmissionSearchEnable = null delayTime = null isLearnDictionaryMode = null + incognitoModeDetectionPreference = true + showLearnedCandidatesInIncognitoPreference = true isUserDictionaryEnable = null isUserTemplateEnable = null hankakuPreference = null @@ -4038,6 +4071,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) + collapseShortcutEntryExpansion() when (newConfig.orientation) { Configuration.ORIENTATION_PORTRAIT -> { finishComposingText() @@ -11910,6 +11944,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, */ private fun updateClipboardPreview() { Timber.d("SuggestionAdapter Clipboard: updateClipboardPreview") + collapseShortcutEntryExpansion() suggestionAdapter?.apply { when (val item = clipboardUtil.getPrimaryClipContent()) { is ClipboardItem.Image -> { @@ -16797,6 +16832,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, adapter.setOnShortcutItemClickListener { type -> handleShortcutAction(type, mainView) } + adapter.setOnShortcutEntryClickListener { + adapter.toggleIntegratedShortcutEntryExpansion() + } } suggestionAdapterFull?.let { adapter -> adapter.setOnItemClickListener { candidate, position -> @@ -16932,6 +16970,25 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun anchorActiveSuggestionStripStartForLeadingContent() { + assertMainThread("anchorActiveSuggestionStripStartForLeadingContent") + measureDebugSection("IMEService.anchorActiveSuggestionStripStartForLeadingContent") { + if (isKeyboardFloatingMode == true) { + floatingKeyboardBinding?.suggestionRecyclerView?.scrollToPosition(0) + return@measureDebugSection + } + val binding = mainLayoutBinding ?: return@measureDebugSection + setMainSuggestionColumn(binding) + binding.suggestionRecyclerView.scrollToPosition(0) + } + } + + private fun anchorActiveSuggestionStripStartIfLeadingContentExpected() { + assertMainThread("anchorActiveSuggestionStripStartIfLeadingContentExpected") + if (suggestionAdapter?.isStartAnchoredContentExpected() != true) return + anchorActiveSuggestionStripStartForLeadingContent() + } + private fun setMainSuggestionColumn( mainView: MainLayoutBinding ) { @@ -16989,6 +17046,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun getSpanSize(position: Int): Int { return when (adapter?.getItemViewType(position)) { SuggestionAdapter.VIEW_TYPE_EMPTY, + SuggestionAdapter.VIEW_TYPE_CLIPBOARD_PREVIEW, + SuggestionAdapter.VIEW_TYPE_SHORTCUT_ENTRY, SuggestionAdapter.VIEW_TYPE_CUSTOM_LAYOUT_PICKER, SuggestionAdapter.VIEW_TYPE_SHORTCUT -> spanCount else -> 1 @@ -17590,17 +17649,25 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return applicationContext.dpToPx(toolbarHeightDp) } - private fun applyShortcutToolbarSize(mainView: MainLayoutBinding) { - val toolbarHeightDp = shortcutToolbarHeightDp.coerceIn( + private fun shortcutToolbarIconSizePx(toolbarHeightDp: Int = shortcutToolbarHeightDp): Int { + val normalizedToolbarHeightDp = toolbarHeightDp.coerceIn( AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP ) val iconSizeDp = appPreference.resolveShortcutToolbarIconSizeDp( - toolbarHeightDp = toolbarHeightDp, + toolbarHeightDp = normalizedToolbarHeightDp, iconSizeDp = shortcutToolbarIconSizeDp ) + return applicationContext.dpToPx(iconSizeDp) + } + + private fun applyShortcutToolbarSize(mainView: MainLayoutBinding) { + val toolbarHeightDp = shortcutToolbarHeightDp.coerceIn( + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) val toolbarHeightPx = applicationContext.dpToPx(toolbarHeightDp) - val iconSizePx = applicationContext.dpToPx(iconSizeDp) + val iconSizePx = shortcutToolbarIconSizePx(toolbarHeightDp) mainView.shortcutToolbarRecyclerview.layoutParams = mainView.shortcutToolbarRecyclerview.layoutParams.apply { height = toolbarHeightPx @@ -17662,7 +17729,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, candidatesShown = candidatesShown, resetCandidateTabSelection = resetCandidateTabSelection ) - suggestionAdapter?.setIntegratedShortcutVisibility(presentation.showIntegratedShortcut) + suggestionAdapter?.setIntegratedShortcutItemsVisibility(presentation.showIntegratedShortcutItems) + suggestionAdapter?.setIntegratedShortcutEntryVisibility(presentation.showIntegratedShortcutEntry) applyCandidateTabSuggestionOffset(mainView, presentation.showCandidateTab) mainView.candidateTabLayout.isVisible = presentation.showCandidateTab if (presentation.resetCandidateTabSelection) { @@ -17678,6 +17746,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun collapseShortcutEntryExpansion() { + suggestionAdapter?.setIntegratedShortcutEntryExpanded(false) + } + private fun handleShortcutAction(type: ShortcutType, mainView: MainLayoutBinding) { when (type) { ShortcutType.SETTINGS -> { @@ -19472,22 +19544,24 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun handlePartialOrExcessLength( - insertString: String, candidate: Candidate + insertString: String, candidate: Candidate, currentInputMode: InputMode ) { val candidateLength = candidate.length.toInt() val candidateString = candidate.string if (insertString.length > candidateLength) { stringInTail.set(insertString.substring(candidateLength)) - ioScope.launch { - learnRepository.upsertLearnedData( - LearnEntity( - input = insertString.substring(0, candidateLength), - out = candidateString, - score = candidate.score.toShort(), - leftId = candidate.leftId, - rightId = candidate.rightId + if (currentInputMode == InputMode.ModeJapanese && isLearnDictionaryMode == true && !isPrivateMode) { + ioScope.launch { + learnRepository.upsertLearnedData( + LearnEntity( + input = insertString.substring(0, candidateLength), + out = candidateString, + score = candidate.score.toShort(), + leftId = candidate.leftId, + rightId = candidate.rightId + ) ) - ) + } } } commitAndClearInput(candidateString) @@ -19548,7 +19622,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } else { handlePartialOrExcessLength( - insertString = insertString, candidate = candidate + insertString = insertString, + candidate = candidate, + currentInputMode = currentInputMode ) } } @@ -19639,6 +19715,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, _tenKeyQWERTYMode.update { TenKeyQWERTYMode.Default } clearZenzLiveSlot("resetAllFlags") setSuggestionAdapterSuggestionsOnMain(emptyList()) + isPrivateMode = false + suggestionAdapter?.setIncognitoIcon(null) stringInTail.set("") suggestionClickNum = 0 currentCustomKeyboardPosition = 0 @@ -20581,12 +20659,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, emptyList() } + val suggestionLearnRepository = learnedRepositoryForSuggestion() val resultFromLearnDictionary = - if (enablePredictionSearchLearnDictionaryPreference == true) { + if (enablePredictionSearchLearnDictionaryPreference == true && + suggestionLearnRepository != null + ) { withContext(Dispatchers.IO) { val prefixMatchNumber = (learnPredictionPreference ?: 2) - 1 if (insertString.length <= prefixMatchNumber) return@withContext emptyList() - learnRepository.predictiveSearchByInput( + suggestionLearnRepository.predictiveSearchByInput( prefix = insertString, limit = 4 ).map { Candidate( @@ -20623,7 +20704,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, isOmissionSearchEnable = isOmissionSearchEnable ?: false, enableTypoCorrectionJapaneseFlick = enableTypoCorrectionJapaneseFlick, enableTypoCorrectionQwertyEnglish = enableTypoCorrectionQwertyEnglish, @@ -20642,7 +20723,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, isOmissionSearchEnable = isOmissionSearchEnable ?: false, enableTypoCorrectionJapaneseFlick = enableTypoCorrectionJapaneseFlick, enableTypoCorrectionQwertyEnglish = enableTypoCorrectionQwertyEnglish, @@ -20737,13 +20818,16 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, emptyList() } + val suggestionLearnRepository = learnedRepositoryForSuggestion() val resultFromLearnDictionary = - if (enablePredictionSearchLearnDictionaryPreference == true) { + if (enablePredictionSearchLearnDictionaryPreference == true && + suggestionLearnRepository != null + ) { measureDebugStage("IMEService.getSuggestionList.learnDictionary") { withContext(Dispatchers.IO) { val prefixMatchNumber = (learnPredictionPreference ?: 2) - 1 if (insertString.length <= prefixMatchNumber) return@withContext emptyList() - learnRepository.predictiveSearchByInput( + suggestionLearnRepository.predictiveSearchByInput( prefix = insertString, limit = 4 ).map { Candidate( @@ -20784,7 +20868,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, isOmissionSearchEnable = isOmissionSearchEnable ?: false, enableTypoCorrectionJapaneseFlick = enableTypoCorrectionJapaneseFlick, enableTypoCorrectionQwertyEnglish = enableTypoCorrectionQwertyEnglish, @@ -20806,7 +20890,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, isOmissionSearchEnable = isOmissionSearchEnable ?: false, enableTypoCorrectionJapaneseFlick = enableTypoCorrectionJapaneseFlick, enableTypoCorrectionQwertyEnglish = enableTypoCorrectionQwertyEnglish, @@ -20926,12 +21010,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, emptyList() } + val suggestionLearnRepository = learnedRepositoryForSuggestion() val resultFromLearnDictionary = - if (enablePredictionSearchLearnDictionaryPreference == true) { + if (enablePredictionSearchLearnDictionaryPreference == true && + suggestionLearnRepository != null + ) { withContext(Dispatchers.IO) { val prefixMatchNumber = (learnPredictionPreference ?: 2) - 1 if (insertString.length <= prefixMatchNumber) return@withContext emptyList() - learnRepository.predictiveSearchByInput( + suggestionLearnRepository.predictiveSearchByInput( prefix = insertString, limit = 4 ).map { Candidate( @@ -20960,7 +21047,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, typoCorrectionOffsetScore = enableTypoCorrectionJapaneseFlickKeyboardOffsetScorePreference ?: 3000, omissionSearchOffsetScore = omissionSearchOffsetScorePreference ?: 1900 @@ -20976,7 +21063,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = mozcUTNeologd, mozcUTWeb = mozcUTWeb, userDictionaryRepository = userDictionaryRepository, - learnRepository = if (isLearnDictionaryMode == true) learnRepository else null, + learnRepository = suggestionLearnRepository, typoCorrectionOffsetScore = enableTypoCorrectionJapaneseFlickKeyboardOffsetScorePreference ?: 3000, omissionSearchOffsetScore = omissionSearchOffsetScorePreference ?: 1900 diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index 9f3f9ce8..f17bef87 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -21,6 +21,8 @@ data class ImePreferencesSnapshot( val isOmissionSearchEnable: Boolean, val delayTime: Int, val isLearnDictionaryMode: Boolean, + val incognitoModeDetectionPreference: Boolean, + val showLearnedCandidatesInIncognitoPreference: Boolean, val isUserDictionaryEnable: Boolean, val isUserTemplateEnable: Boolean, val hankakuPreference: Boolean, @@ -265,6 +267,10 @@ data class ImePreferencesSnapshot( isOmissionSearchEnable = appPreference.omission_search_preference ?: false, delayTime = appPreference.time_same_pronounce_typing_preference ?: 1000, isLearnDictionaryMode = appPreference.learn_dictionary_preference ?: true, + incognitoModeDetectionPreference = + appPreference.incognito_mode_detection_preference, + showLearnedCandidatesInIncognitoPreference = + appPreference.show_learned_candidates_in_incognito_preference, isUserDictionaryEnable = appPreference.user_dictionary_preference ?: true, isUserTemplateEnable = appPreference.user_template_preference ?: true, hankakuPreference = appPreference.space_hankaku_preference ?: false, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ShortcutToolbarPresentationPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ShortcutToolbarPresentationPolicy.kt index 95e268fc..9daeed9c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ShortcutToolbarPresentationPolicy.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ShortcutToolbarPresentationPolicy.kt @@ -14,7 +14,8 @@ data class ShortcutToolbarPresentationState( data class ShortcutToolbarPresentation( val showIndependentToolbar: Boolean, - val showIntegratedShortcuts: Boolean + val showIntegratedShortcutItems: Boolean, + val showIntegratedShortcutEntry: Boolean ) object ShortcutToolbarPresentationPolicy { @@ -23,25 +24,35 @@ object ShortcutToolbarPresentationPolicy { if (!state.shortcutToolbarVisible || state.symbolKeyboardShown) { return ShortcutToolbarPresentation( showIndependentToolbar = false, - showIntegratedShortcuts = false + showIntegratedShortcutItems = false, + showIntegratedShortcutEntry = false ) } if (!state.integratedInSuggestion) { return ShortcutToolbarPresentation( showIndependentToolbar = true, - showIntegratedShortcuts = false + showIntegratedShortcutItems = false, + showIntegratedShortcutEntry = false ) } - val showIntegratedShortcuts = + val showIntegratedShortcutItems = state.inputStringEmpty && state.tailEmpty && !state.clipboardPreviewShown && !state.selectedTextGemmaActionsShown && state.suggestionsEmpty && !state.customLayoutPickerShown + val isCentralSpecialContentShown = + state.clipboardPreviewShown || state.selectedTextGemmaActionsShown + val showIntegratedShortcutEntry = + state.inputStringEmpty && + state.tailEmpty && + isCentralSpecialContentShown && + !state.customLayoutPickerShown return ShortcutToolbarPresentation( showIndependentToolbar = false, - showIntegratedShortcuts = showIntegratedShortcuts + showIntegratedShortcutItems = showIntegratedShortcutItems, + showIntegratedShortcutEntry = showIntegratedShortcutEntry ) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt index bba1fffc..46ec263e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt @@ -111,6 +111,8 @@ class SuggestionAdapter : RecyclerView.Adapter() { const val VIEW_TYPE_CUSTOM_LAYOUT_PICKER = 2 const val VIEW_TYPE_GEMMA_ACTION = 3 const val VIEW_TYPE_SHORTCUT = 4 + const val VIEW_TYPE_CLIPBOARD_PREVIEW = 5 + const val VIEW_TYPE_SHORTCUT_ENTRY = 6 private val diffThreadIndex = AtomicInteger(0) private val diffExecutor: Executor = Executors.newFixedThreadPool(2) { runnable -> @@ -124,6 +126,34 @@ class SuggestionAdapter : RecyclerView.Adapter() { UNDO, REDO, RECONVERT, PASTE } + internal enum class SuggestionDisplayItemKind { + CandidateItem, + GemmaActionItem, + QuickActionsItem, + ClipboardPreviewItem, + ShortcutEntryItem, + ShortcutItem, + CustomLayoutItem + } + + internal enum class StartAnchorRole { + QuickActions, + ShortcutItems, + ShortcutEntry + } + + internal data class QuickActionsVisibilitySignature( + val incognitoVisible: Boolean, + val undoVisible: Boolean, + val redoVisible: Boolean, + val reconvertVisible: Boolean + ) + + internal data class StartAnchorSignature( + val role: StartAnchorRole, + val quickActions: QuickActionsVisibilitySignature? = null + ) + private sealed class SuggestionDisplayItem { data class CandidateItem( val candidate: Candidate, @@ -135,10 +165,16 @@ class SuggestionAdapter : RecyclerView.Adapter() { val candidateIndex: Int, ) : SuggestionDisplayItem() - data class HelperActionsItem( - val state: HelperActionsState, + data class QuickActionsItem( + val state: QuickActionsState, ) : SuggestionDisplayItem() + data class ClipboardPreviewItem( + val state: ClipboardPreviewState, + ) : SuggestionDisplayItem() + + object ShortcutEntryItem : SuggestionDisplayItem() + data class ShortcutItem( val shortcutType: ShortcutType, ) : SuggestionDisplayItem() @@ -149,29 +185,32 @@ class SuggestionAdapter : RecyclerView.Adapter() { ) : SuggestionDisplayItem() } - private data class HelperActionsState( + private data class QuickActionsState( val undoEnabled: Boolean, val redoEnabled: Boolean, val reconvertEnabled: Boolean, - val pasteEnabled: Boolean, - val clipboardDescriptionShown: Boolean, - val clipboardText: String, - val clipboardBitmap: Bitmap?, val undoText: String, val redoText: String, val incognitoIconDrawable: android.graphics.drawable.Drawable?, ) { - val hasClipboardPreview: Boolean - get() = pasteEnabled && (clipboardBitmap != null || clipboardText.isNotBlank()) - val hasVisibleAction: Boolean get() = undoEnabled || redoEnabled || reconvertEnabled || - pasteEnabled || incognitoIconDrawable != null } + private data class ClipboardPreviewState( + val pasteEnabled: Boolean, + val clipboardDescriptionShown: Boolean, + val clipboardText: String, + val clipboardBitmap: Bitmap?, + val hasLeadingShortcutEntry: Boolean + ) { + val hasClipboardPreview: Boolean + get() = pasteEnabled && (clipboardBitmap != null || clipboardText.isNotBlank()) + } + // Listeners for clicks private var onItemClickListener: ((Candidate, Int) -> Unit)? = null private var onItemLongClickListener: ((Candidate, Int) -> Unit)? = null @@ -179,10 +218,12 @@ class SuggestionAdapter : RecyclerView.Adapter() { private var onItemHelperIconLongClickListener: ((HelperIcon) -> Unit)? = null private var onCustomLayoutItemClickListener: ((Int) -> Unit)? = null private var onShortcutItemClickListener: ((ShortcutType) -> Unit)? = null + private var onShortcutEntryClickListener: ((View) -> Unit)? = null private var onShowSoftKeyboardClick: (() -> Unit)? = null private val adapterScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) var onListUpdated: (() -> Unit)? = null + var onStartAnchoredContentCommitted: (() -> Unit)? = null // Holds the preview content for the empty state. private var clipboardText: String = "" @@ -203,7 +244,9 @@ class SuggestionAdapter : RecyclerView.Adapter() { private var showCustomTab: Boolean = true private var shortcutItems: List = emptyList() - private var showIntegratedShortcuts: Boolean = false + private var showIntegratedShortcutItems: Boolean = false + private var showIntegratedShortcutEntry: Boolean = false + private var integratedShortcutEntryExpanded: Boolean = false private var shortcutIconColor: Int? = null private var activeShortcutTypes: Set = emptySet() @@ -218,6 +261,7 @@ class SuggestionAdapter : RecyclerView.Adapter() { private var candidateEmptyDrawableTextColor: Int? = null private var released: Boolean = false private var displayGeneration: Int = 0 + private var committedStartAnchorSignature: StartAnchorSignature? = null fun setOnItemClickListener(onItemClick: (Candidate, Int) -> Unit) { this.onItemClickListener = onItemClick @@ -243,6 +287,10 @@ class SuggestionAdapter : RecyclerView.Adapter() { this.onShortcutItemClickListener = listener } + fun setOnShortcutEntryClickListener(listener: (View) -> Unit) { + this.onShortcutEntryClickListener = listener + } + fun setOnPhysicalKeyboardListener(listener: () -> Unit) { this.onShowSoftKeyboardClick = listener } @@ -255,8 +303,10 @@ class SuggestionAdapter : RecyclerView.Adapter() { onItemHelperIconLongClickListener = null onCustomLayoutItemClickListener = null onShortcutItemClickListener = null + onShortcutEntryClickListener = null onShowSoftKeyboardClick = null onListUpdated = null + onStartAnchoredContentCommitted = null incognitoIconDrawable = null adapterScope.cancel() } @@ -324,7 +374,7 @@ class SuggestionAdapter : RecyclerView.Adapter() { } fun isShowingClipboardPreviewForEmptyState(): Boolean { - return currentHelperActionsState().hasClipboardPreview + return currentClipboardPreviewState(hasLeadingShortcutEntry = false).hasClipboardPreview } fun isShowingCustomLayoutPicker(): Boolean { @@ -332,21 +382,54 @@ class SuggestionAdapter : RecyclerView.Adapter() { } fun setShortcutItems(items: List) { - if (shortcutItems == items) return + val shouldCollapseExpandedEntry = items.isEmpty() && integratedShortcutEntryExpanded + if (shortcutItems == items && !shouldCollapseExpandedEntry) return shortcutItems = items + if (items.isEmpty()) { + integratedShortcutEntryExpanded = false + } rebuildDisplayItems() } - fun setIntegratedShortcutVisibility(visible: Boolean) { - if (showIntegratedShortcuts == visible) return - showIntegratedShortcuts = visible + fun setIntegratedShortcutItemsVisibility(visible: Boolean) { + val shouldCollapseExpandedEntry = visible && integratedShortcutEntryExpanded + if (showIntegratedShortcutItems == visible && !shouldCollapseExpandedEntry) return + showIntegratedShortcutItems = visible + if (visible) { + integratedShortcutEntryExpanded = false + } rebuildDisplayItems() } + fun setIntegratedShortcutEntryVisibility(visible: Boolean) { + val shouldCollapseExpandedEntry = !visible && integratedShortcutEntryExpanded + if (showIntegratedShortcutEntry == visible && !shouldCollapseExpandedEntry) return + showIntegratedShortcutEntry = visible + if (!visible) { + integratedShortcutEntryExpanded = false + } + rebuildDisplayItems() + } + + fun setIntegratedShortcutEntryExpanded(expanded: Boolean) { + val normalizedExpanded = + expanded && + showIntegratedShortcutEntry && + shortcutItems.isNotEmpty() && + hasSwitchableShortcutEntryContent() + if (integratedShortcutEntryExpanded == normalizedExpanded) return + integratedShortcutEntryExpanded = normalizedExpanded + rebuildDisplayItems() + } + + fun toggleIntegratedShortcutEntryExpansion() { + setIntegratedShortcutEntryExpanded(!integratedShortcutEntryExpanded) + } + fun setShortcutIconColor(color: Int) { if (shortcutIconColor == color) return shortcutIconColor = color - if (showIntegratedShortcuts && suggestions.isEmpty()) { + if (showIntegratedShortcutItems || showIntegratedShortcutEntry) { notifyItemRangeChanged(0, itemCount) } } @@ -417,8 +500,14 @@ class SuggestionAdapter : RecyclerView.Adapter() { oldItem.candidate.string == newItem.candidate.string && oldItem.candidate.type == newItem.candidate.type - oldItem is SuggestionDisplayItem.HelperActionsItem && - newItem is SuggestionDisplayItem.HelperActionsItem -> true + oldItem is SuggestionDisplayItem.QuickActionsItem && + newItem is SuggestionDisplayItem.QuickActionsItem -> true + + oldItem is SuggestionDisplayItem.ClipboardPreviewItem && + newItem is SuggestionDisplayItem.ClipboardPreviewItem -> true + + oldItem is SuggestionDisplayItem.ShortcutEntryItem && + newItem is SuggestionDisplayItem.ShortcutEntryItem -> true oldItem is SuggestionDisplayItem.ShortcutItem && newItem is SuggestionDisplayItem.ShortcutItem -> @@ -498,21 +587,40 @@ class SuggestionAdapter : RecyclerView.Adapter() { val newItems = buildDisplayItems() if (displayItems == newItems) return@measureDebugSection + val newStartAnchorSignature = startAnchorSignatureFor(newItems) val generation = ++displayGeneration differ.submitList(newItems) { if (released || generation != displayGeneration) return@submitList + val previousStartAnchorSignature = committedStartAnchorSignature + committedStartAnchorSignature = newStartAnchorSignature onCommitted?.invoke() + if (released || generation != displayGeneration) return@submitList + if ( + newStartAnchorSignature != null && + previousStartAnchorSignature != newStartAnchorSignature + ) { + onStartAnchoredContentCommitted?.invoke() + } } } } private fun buildDisplayItems(): List { + if (shouldShowExpandedShortcutEntryItems()) { + return buildExpandedShortcutEntryItems() + } + if (candidateSuggestions.isNotEmpty()) { - return candidateSuggestions.mapIndexed { index, candidate -> - if (candidate.isSelectedTextGemmaActionCandidate()) { - SuggestionDisplayItem.GemmaActionItem(candidate, index) - } else { - SuggestionDisplayItem.CandidateItem(candidate, index) + return buildList { + if (isShowingSelectedTextGemmaActions() && shouldShowIntegratedShortcutEntry()) { + add(SuggestionDisplayItem.ShortcutEntryItem) + } + candidateSuggestions.forEachIndexed { index, candidate -> + if (candidate.isSelectedTextGemmaActionCandidate()) { + add(SuggestionDisplayItem.GemmaActionItem(candidate, index)) + } else { + add(SuggestionDisplayItem.CandidateItem(candidate, index)) + } } } } @@ -524,11 +632,23 @@ class SuggestionAdapter : RecyclerView.Adapter() { } return buildList { - val helperState = currentHelperActionsState() - if (helperState.hasVisibleAction) { - add(SuggestionDisplayItem.HelperActionsItem(helperState)) + val showShortcutEntry = shouldShowIntegratedShortcutEntry() + val clipboardPreviewState = currentClipboardPreviewState( + hasLeadingShortcutEntry = showShortcutEntry + ) + if (clipboardPreviewState.hasClipboardPreview) { + if (showShortcutEntry) { + add(SuggestionDisplayItem.ShortcutEntryItem) + } + add(SuggestionDisplayItem.ClipboardPreviewItem(clipboardPreviewState)) + return@buildList } - if (shouldShowIntegratedShortcuts()) { + + val quickActionsState = currentQuickActionsState() + if (quickActionsState.hasVisibleAction) { + add(SuggestionDisplayItem.QuickActionsItem(quickActionsState)) + } + if (shouldShowIntegratedShortcutItems()) { shortcutItems.forEach { shortcutType -> add(SuggestionDisplayItem.ShortcutItem(shortcutType)) } @@ -536,20 +656,85 @@ class SuggestionAdapter : RecyclerView.Adapter() { } } - private fun currentHelperActionsState(): HelperActionsState = - HelperActionsState( + private fun buildExpandedShortcutEntryItems(): List = + buildList { + add(SuggestionDisplayItem.ShortcutEntryItem) + shortcutItems.forEach { shortcutType -> + add(SuggestionDisplayItem.ShortcutItem(shortcutType)) + } + } + + internal fun buildDisplayItemKindsForTesting(): List { + return buildDisplayItems().map { it.kind() } + } + + internal fun buildStartAnchorSignatureForTesting(): StartAnchorSignature? { + return startAnchorSignatureFor(buildDisplayItems()) + } + + internal fun isStartAnchoredContentExpected(): Boolean { + return startAnchorSignatureFor(buildDisplayItems()) != null + } + + private fun currentQuickActionsState(): QuickActionsState = + QuickActionsState( undoEnabled = isUndoEnabled, redoEnabled = isRedoEnabled, reconvertEnabled = isReconvertEnabled, + undoText = undoText, + redoText = redoText, + incognitoIconDrawable = incognitoIconDrawable, + ) + + private fun currentClipboardPreviewState( + hasLeadingShortcutEntry: Boolean + ): ClipboardPreviewState = + ClipboardPreviewState( pasteEnabled = isPasteEnabled, clipboardDescriptionShown = isClipboardDescriptionShow, clipboardText = clipboardText, clipboardBitmap = clipboardBitmap, - undoText = undoText, - redoText = redoText, - incognitoIconDrawable = incognitoIconDrawable, + hasLeadingShortcutEntry = hasLeadingShortcutEntry, ) + private fun SuggestionDisplayItem.kind(): SuggestionDisplayItemKind = + when (this) { + is SuggestionDisplayItem.CandidateItem -> + SuggestionDisplayItemKind.CandidateItem + is SuggestionDisplayItem.GemmaActionItem -> + SuggestionDisplayItemKind.GemmaActionItem + is SuggestionDisplayItem.QuickActionsItem -> + SuggestionDisplayItemKind.QuickActionsItem + is SuggestionDisplayItem.ClipboardPreviewItem -> + SuggestionDisplayItemKind.ClipboardPreviewItem + SuggestionDisplayItem.ShortcutEntryItem -> + SuggestionDisplayItemKind.ShortcutEntryItem + is SuggestionDisplayItem.ShortcutItem -> + SuggestionDisplayItemKind.ShortcutItem + is SuggestionDisplayItem.CustomLayoutItem -> + SuggestionDisplayItemKind.CustomLayoutItem + } + + private fun startAnchorSignatureFor(items: List): StartAnchorSignature? { + return when (val first = items.firstOrNull()) { + is SuggestionDisplayItem.QuickActionsItem -> + StartAnchorSignature( + role = StartAnchorRole.QuickActions, + quickActions = QuickActionsVisibilitySignature( + incognitoVisible = first.state.incognitoIconDrawable != null, + undoVisible = first.state.undoEnabled, + redoVisible = first.state.redoEnabled, + reconvertVisible = first.state.reconvertEnabled + ) + ) + SuggestionDisplayItem.ShortcutEntryItem -> + StartAnchorSignature(role = StartAnchorRole.ShortcutEntry) + is SuggestionDisplayItem.ShortcutItem -> + StartAnchorSignature(role = StartAnchorRole.ShortcutItems) + else -> null + } + } + inner class SuggestionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val text: MaterialTextView = itemView.findViewById(R.id.suggestion_item_text_view) val yomiText: MaterialTextView = itemView.findViewById(R.id.suggestion_item_yomi_text_view) @@ -561,7 +746,7 @@ class SuggestionAdapter : RecyclerView.Adapter() { val actionText: MaterialTextView = itemView.findViewById(R.id.suggestion_gemma_action_text) } - inner class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + inner class QuickActionsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val undoIconParent: ConstraintLayout? = itemView.findViewById(R.id.undo_icon_parent) val undoImageView: ImageView? = itemView.findViewById(R.id.imageView) val undoIcon: MaterialTextView? = itemView.findViewById(R.id.undo_icon) @@ -571,13 +756,16 @@ class SuggestionAdapter : RecyclerView.Adapter() { val reconvertIconParent: ConstraintLayout? = itemView.findViewById(R.id.reconvert_icon_parent) val reconvertIcon: MaterialTextView? = itemView.findViewById(R.id.reconvert_icon) val reconvertImageView: ImageView? = itemView.findViewById(R.id.reconvert_image_view) + val incognitoIcon: AppCompatImageButton? = itemView.findViewById(R.id.incognito_icon) + } + + inner class ClipboardPreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val pasteIconParent: ConstraintLayout? = itemView.findViewById(R.id.paste_icon_patent) val pasteIcon: ImageView? = itemView.findViewById(R.id.paste_icon) val clipboardPreviewText: MaterialTextView? = itemView.findViewById(R.id.clipboard_text_preview) val clipboardPreviewTextDescription: MaterialTextView? = itemView.findViewById(R.id.clipboard_preview_text_description) - val incognitoIcon: AppCompatImageButton? = itemView.findViewById(R.id.incognito_icon) } inner class CustomLayoutViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -588,11 +776,17 @@ class SuggestionAdapter : RecyclerView.Adapter() { val imageView: ImageView = itemView.findViewById(R.id.item_image) } + inner class ShortcutEntryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val imageView: ImageView = itemView.findViewById(R.id.shortcut_entry_image) + } + override fun getItemViewType(position: Int): Int { return when (displayItems[position]) { is SuggestionDisplayItem.CandidateItem -> VIEW_TYPE_SUGGESTION is SuggestionDisplayItem.GemmaActionItem -> VIEW_TYPE_GEMMA_ACTION - is SuggestionDisplayItem.HelperActionsItem -> VIEW_TYPE_EMPTY + is SuggestionDisplayItem.QuickActionsItem -> VIEW_TYPE_EMPTY + is SuggestionDisplayItem.ClipboardPreviewItem -> VIEW_TYPE_CLIPBOARD_PREVIEW + SuggestionDisplayItem.ShortcutEntryItem -> VIEW_TYPE_SHORTCUT_ENTRY is SuggestionDisplayItem.ShortcutItem -> VIEW_TYPE_SHORTCUT is SuggestionDisplayItem.CustomLayoutItem -> VIEW_TYPE_CUSTOM_LAYOUT_PICKER } @@ -607,8 +801,14 @@ class SuggestionAdapter : RecyclerView.Adapter() { return when (viewType) { VIEW_TYPE_EMPTY -> { val emptyView = LayoutInflater.from(parent.context) - .inflate(R.layout.suggestion_empty_layout, parent, false) - EmptyViewHolder(emptyView) + .inflate(R.layout.suggestion_quick_actions_item, parent, false) + QuickActionsViewHolder(emptyView) + } + + VIEW_TYPE_CLIPBOARD_PREVIEW -> { + val clipboardPreviewView = LayoutInflater.from(parent.context) + .inflate(R.layout.suggestion_clipboard_preview_item, parent, false) + ClipboardPreviewViewHolder(clipboardPreviewView) } VIEW_TYPE_CUSTOM_LAYOUT_PICKER -> { @@ -641,16 +841,26 @@ class SuggestionAdapter : RecyclerView.Adapter() { ShortcutViewHolder(itemView) } + VIEW_TYPE_SHORTCUT_ENTRY -> { + val itemView = LayoutInflater.from(parent.context) + .inflate(R.layout.suggestion_shortcut_entry_item, parent, false) + ShortcutEntryViewHolder(itemView) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = displayItems.getOrNull(position) ?: return - when (holder.itemViewType) { - VIEW_TYPE_EMPTY -> onBindEmptyViewHolder( - holder as EmptyViewHolder, - (item as SuggestionDisplayItem.HelperActionsItem).state, + when (getItemViewType(position)) { + VIEW_TYPE_EMPTY -> onBindQuickActionsViewHolder( + holder as QuickActionsViewHolder, + (item as SuggestionDisplayItem.QuickActionsItem).state, + ) + VIEW_TYPE_CLIPBOARD_PREVIEW -> onBindClipboardPreviewViewHolder( + holder as ClipboardPreviewViewHolder, + (item as SuggestionDisplayItem.ClipboardPreviewItem).state, ) VIEW_TYPE_SUGGESTION -> onBindSuggestionViewHolder( holder as SuggestionViewHolder, @@ -666,6 +876,10 @@ class SuggestionAdapter : RecyclerView.Adapter() { item as SuggestionDisplayItem.ShortcutItem, ) + VIEW_TYPE_SHORTCUT_ENTRY -> onBindShortcutEntryViewHolder( + holder as ShortcutEntryViewHolder, + ) + VIEW_TYPE_CUSTOM_LAYOUT_PICKER -> onBindCustomLayoutViewHolder( holder as CustomLayoutViewHolder, item as SuggestionDisplayItem.CustomLayoutItem, @@ -673,9 +887,8 @@ class SuggestionAdapter : RecyclerView.Adapter() { } } - private fun onBindEmptyViewHolder(holder: EmptyViewHolder, state: HelperActionsState) { + private fun onBindQuickActionsViewHolder(holder: QuickActionsViewHolder, state: QuickActionsState) { val isDynamicColorEnable = DynamicColors.isDynamicColorAvailable() - Timber.d("SuggestionAdapter onBindEmptyViewHolder: ${state.clipboardText} ${state.pasteEnabled}") holder.apply { incognitoIcon?.apply { if (state.incognitoIconDrawable != null) { @@ -702,25 +915,6 @@ class SuggestionAdapter : RecyclerView.Adapter() { isVisible = state.reconvertEnabled isFocusable = false } - pasteIconParent?.apply { - isEnabled = state.pasteEnabled - visibility = if (state.pasteEnabled) View.VISIBLE else View.GONE - isFocusable = false - } - - pasteIcon?.apply { - if (state.clipboardBitmap != null) { - setImageBitmap(state.clipboardBitmap) - clearColorFilter() - scaleType = ImageView.ScaleType.CENTER_CROP - } else { - setImageResource(com.kazumaproject.core.R.drawable.content_paste_24px) - scaleType = ImageView.ScaleType.CENTER_INSIDE - } - } - - clipboardPreviewText?.text = - if (state.clipboardBitmap == null) state.clipboardText else "" applyEmptyHelperButtonStyle( parent = undoIconParent, @@ -740,13 +934,6 @@ class SuggestionAdapter : RecyclerView.Adapter() { icon = reconvertImageView, isDynamicColorEnable = isDynamicColorEnable, ) - applyEmptyHelperButtonStyle( - parent = pasteIconParent, - text = clipboardPreviewText, - icon = if (state.clipboardBitmap == null) pasteIcon else null, - isDynamicColorEnable = isDynamicColorEnable, - ) - applyEmptyHelperTextColor(clipboardPreviewTextDescription) undoIconParent?.apply { isVisible = state.undoEnabled @@ -779,9 +966,49 @@ class SuggestionAdapter : RecyclerView.Adapter() { false } } + } + } - clipboardPreviewTextDescription?.isVisible = false + private fun onBindClipboardPreviewViewHolder( + holder: ClipboardPreviewViewHolder, + state: ClipboardPreviewState + ) { + val isDynamicColorEnable = DynamicColors.isDynamicColorAvailable() + Timber.d("SuggestionAdapter onBindClipboardPreviewViewHolder: ${state.clipboardText} ${state.pasteEnabled}") + holder.itemView.translationX = if (state.hasLeadingShortcutEntry) { + -holder.itemView.resources.displayMetrics.density * 28f + } else { + 0f + } + holder.apply { + pasteIconParent?.apply { + isEnabled = state.pasteEnabled + visibility = if (state.pasteEnabled) View.VISIBLE else View.GONE + isFocusable = false + } + pasteIcon?.apply { + if (state.clipboardBitmap != null) { + setImageBitmap(state.clipboardBitmap) + clearColorFilter() + scaleType = ImageView.ScaleType.CENTER_CROP + } else { + setImageResource(com.kazumaproject.core.R.drawable.content_paste_24px) + scaleType = ImageView.ScaleType.CENTER_INSIDE + } + } + + clipboardPreviewText?.text = + if (state.clipboardBitmap == null) state.clipboardText else "" + + applyEmptyHelperButtonStyle( + parent = pasteIconParent, + text = clipboardPreviewText, + icon = if (state.clipboardBitmap == null) pasteIcon else null, + isDynamicColorEnable = isDynamicColorEnable, + ) + applyEmptyHelperTextColor(clipboardPreviewTextDescription) + clipboardPreviewTextDescription?.isVisible = false pasteIconParent?.apply { setOnClickListener { onItemHelperIconClickListener?.invoke(HelperIcon.PASTE) @@ -841,13 +1068,35 @@ class SuggestionAdapter : RecyclerView.Adapter() { } } - private fun shouldShowIntegratedShortcuts(): Boolean { - return showIntegratedShortcuts && + private fun shouldShowIntegratedShortcutItems(): Boolean { + return showIntegratedShortcutItems && shortcutItems.isNotEmpty() && + candidateSuggestions.isEmpty() && !isShowingCustomLayoutPicker() && !isShowingClipboardPreviewForEmptyState() } + private fun shouldShowIntegratedShortcutEntry(): Boolean { + return showIntegratedShortcutEntry && + shortcutItems.isNotEmpty() && + !isShowingCustomLayoutPicker() + } + + private fun shouldShowExpandedShortcutEntryItems(): Boolean { + if (!integratedShortcutEntryExpanded || !shouldShowIntegratedShortcutEntry()) return false + return hasSwitchableShortcutEntryContent() + } + + private fun hasSwitchableShortcutEntryContent(): Boolean { + return isShowingSelectedTextGemmaActions() || + currentClipboardPreviewState(hasLeadingShortcutEntry = false).hasClipboardPreview + } + + private fun isShowingSelectedTextGemmaActions(): Boolean { + return candidateSuggestions.isNotEmpty() && + candidateSuggestions.all { it.isSelectedTextGemmaActionCandidate() } + } + private fun onBindShortcutViewHolder( holder: ShortcutViewHolder, item: SuggestionDisplayItem.ShortcutItem, @@ -872,6 +1121,23 @@ class SuggestionAdapter : RecyclerView.Adapter() { } } + private fun onBindShortcutEntryViewHolder( + holder: ShortcutEntryViewHolder, + ) { + holder.imageView.apply { + setImageResource(R.drawable.more_horiz_24px) + contentDescription = context.getString(R.string.shortcut_entry_content_description) + shortcutIconColor?.let { color -> + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } ?: clearColorFilter() + } + holder.itemView.contentDescription = + holder.itemView.context.getString(R.string.shortcut_entry_content_description) + holder.itemView.setOnClickListener { + onShortcutEntryClickListener?.invoke(holder.itemView) + } + } + private fun ShortcutType.resolveShortcutIconResId(): Int { return if (this in activeShortcutTypes) { activeIconResId ?: iconResId diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index 22c34817..54ae1b8a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -62,6 +62,10 @@ object AppPreference { private val KEY_SOUND_VOLUME_PERCENT_PREFERENCE = Pair("key_sound_volume_percent_preference", 0) private val LEARN_DICTIONARY_PREFERENCE = Pair("learn_dictionary_preference", true) + private val INCOGNITO_MODE_DETECTION_PREFERENCE = + Pair("incognito_mode_detection_preference", true) + private val SHOW_LEARNED_CANDIDATES_IN_INCOGNITO_PREFERENCE = + Pair("show_learned_candidates_in_incognito_preference", true) private val USER_DICTIONARY_PREFERENCE = Pair("user_dictionary_preference", true) private val USER_DICTIONARY_PREFIX_PREFERENCE = Pair("user_dictionary_prefix_match_number", 2) private val USER_TEMPLATE_PREFERENCE = Pair("user_template_preference", true) @@ -1364,6 +1368,24 @@ object AppPreference { it.putBoolean(LEARN_DICTIONARY_PREFERENCE.first, value ?: true) } + var incognito_mode_detection_preference: Boolean + get() = preferences.getBoolean( + INCOGNITO_MODE_DETECTION_PREFERENCE.first, + INCOGNITO_MODE_DETECTION_PREFERENCE.second + ) + set(value) = preferences.edit { + it.putBoolean(INCOGNITO_MODE_DETECTION_PREFERENCE.first, value) + } + + var show_learned_candidates_in_incognito_preference: Boolean + get() = preferences.getBoolean( + SHOW_LEARNED_CANDIDATES_IN_INCOGNITO_PREFERENCE.first, + SHOW_LEARNED_CANDIDATES_IN_INCOGNITO_PREFERENCE.second + ) + set(value) = preferences.edit { + it.putBoolean(SHOW_LEARNED_CANDIDATES_IN_INCOGNITO_PREFERENCE.first, value) + } + var user_dictionary_preference: Boolean? get() = preferences.getBoolean( USER_DICTIONARY_PREFERENCE.first, USER_DICTIONARY_PREFERENCE.second diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt index ca327a5e..b91fbe77 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt @@ -862,14 +862,14 @@ class CandidateHeightLandscapeSettingFragment : Fragment() { } } suggestionAdapter.setShortcutItems(previewShortcutItems()) - suggestionAdapter.setIntegratedShortcutVisibility(presentation.showIntegratedShortcut) + suggestionAdapter.setIntegratedShortcutVisibility(presentation.showIntegratedShortcutItems) } private data class CandidateHeightPreviewPresentation( val showCandidateTab: Boolean, val showIndependentShortcutToolbar: Boolean, val reserveIndependentShortcutToolbarSpace: Boolean, - val showIntegratedShortcut: Boolean, + val showIntegratedShortcutItems: Boolean, val candidateTabOffsetPx: Int, val independentShortcutToolbarHeightPx: Int ) @@ -913,7 +913,7 @@ class CandidateHeightLandscapeSettingFragment : Fragment() { showIndependentShortcutToolbar = presentation.showIndependentShortcutToolbar, reserveIndependentShortcutToolbarSpace = presentation.reserveIndependentShortcutToolbarSpace, - showIntegratedShortcut = presentation.showIntegratedShortcut, + showIntegratedShortcutItems = presentation.showIntegratedShortcutItems, candidateTabOffsetPx = if (presentation.showCandidateTab) 36.dpToPx() else 0, independentShortcutToolbarHeightPx = independentHeightPx ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt index 8ea4fc58..16f62d4e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt @@ -854,14 +854,14 @@ class CandidateViewHeightSettingFragment : Fragment() { } } suggestionAdapter.setShortcutItems(previewShortcutItems()) - suggestionAdapter.setIntegratedShortcutVisibility(presentation.showIntegratedShortcut) + suggestionAdapter.setIntegratedShortcutVisibility(presentation.showIntegratedShortcutItems) } private data class CandidateHeightPreviewPresentation( val showCandidateTab: Boolean, val showIndependentShortcutToolbar: Boolean, val reserveIndependentShortcutToolbarSpace: Boolean, - val showIntegratedShortcut: Boolean, + val showIntegratedShortcutItems: Boolean, val candidateTabOffsetPx: Int, val independentShortcutToolbarHeightPx: Int ) @@ -905,7 +905,7 @@ class CandidateViewHeightSettingFragment : Fragment() { showIndependentShortcutToolbar = presentation.showIndependentShortcutToolbar, reserveIndependentShortcutToolbarSpace = presentation.reserveIndependentShortcutToolbarSpace, - showIntegratedShortcut = presentation.showIntegratedShortcut, + showIntegratedShortcutItems = presentation.showIntegratedShortcutItems, candidateTabOffsetPx = if (presentation.showCandidateTab) 36.dpToPx() else 0, independentShortcutToolbarHeightPx = independentHeightPx ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt index 89266d60..fe98d542 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt @@ -448,6 +448,8 @@ object SettingDestinations { "candidate_order_override_preference", "ng_word_preference", "learn_dictionary_preference", + "incognito_mode_detection_preference", + "show_learned_candidates_in_incognito_preference", "user_dictionary_preference", "user_template_preference", "enable_ai_conversion_zenz_preference", diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt index 83a79b14..c039aa81 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt @@ -67,6 +67,7 @@ object SettingSearchIndex { R.id.advancedPreferenceFragment, R.id.zenzPreferenceFragment, R.id.gemmaPreferenceFragment, + R.id.legacyCommonPreferenceFragment, ) fun searchable(context: Context): List { @@ -277,6 +278,7 @@ object SettingSearchIndex { } private fun sources(): List = buildList { + add(PreferenceXmlSource(R.xml.pref_common_legacy, R.id.legacyCommonPreferenceFragment, SettingCategory.ADVANCED)) add(PreferenceXmlSource(R.xml.pref_keyboard_display, R.id.keyboardDisplayPreferenceFragment, SettingCategory.KEYBOARD_DISPLAY)) add(PreferenceXmlSource(R.xml.pref_input_method, R.id.inputMethodPreferenceFragment, SettingCategory.INPUT_METHOD)) add(PreferenceXmlSource(R.xml.pref_candidate_conversion, R.id.candidateConversionPreferenceFragment, SettingCategory.CANDIDATE_CONVERSION)) diff --git a/app/src/main/res/drawable/more_horiz_24px.xml b/app/src/main/res/drawable/more_horiz_24px.xml new file mode 100644 index 00000000..70279656 --- /dev/null +++ b/app/src/main/res/drawable/more_horiz_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/suggestion_clipboard_preview_item.xml b/app/src/main/res/layout/suggestion_clipboard_preview_item.xml new file mode 100644 index 00000000..fde7579a --- /dev/null +++ b/app/src/main/res/layout/suggestion_clipboard_preview_item.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/suggestion_quick_actions_item.xml b/app/src/main/res/layout/suggestion_quick_actions_item.xml new file mode 100644 index 00000000..a991df18 --- /dev/null +++ b/app/src/main/res/layout/suggestion_quick_actions_item.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/suggestion_shortcut_entry_item.xml b/app/src/main/res/layout/suggestion_shortcut_entry_item.xml new file mode 100644 index 00000000..767e76ae --- /dev/null +++ b/app/src/main/res/layout/suggestion_shortcut_entry_item.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e55f79fd..d03aa65c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -266,6 +266,12 @@ 学習辞書 学習辞書を有効化\n学習した変換候補を表示します 学習辞書を無効化\n学習した変換候補を表示しません + シークレットモードの検知 + 入力先が個人用学習の無効化を要求したとき、アイコンを表示し、新しい学習を保存しません。 + 入力先からの個人用学習無効化要求を無視します。 + シークレットモード中の学習済み候補 + シークレットモードを検知している間も、保存済みの学習候補を使用します。 + シークレットモードを検知している間は、保存済みの学習候補を使用しません。 ユーザー辞書の予測変換を有効化\n登録した単語を予測変換として変換候補を表示します ユーザー辞書の予測変換を無効化 @@ -563,6 +569,7 @@ 変換候補ビューの上部に「予測」「変換」「英数・カナ」の切り替えタブを表示します。 ショートカットツールバー 絵文字キーボードへの切り替えなどの便利なショートカットアイコンを含むツールバーを表示します + ショートカットを表示 ショートカットを候補欄に統合 入力中でないとき、クリップボードプレビューやAI選択アクションを表示していない場合に、ショートカットアイコンを候補欄に表示します。 ローマ字/英語切り替えキー diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 121e5dc3..23855cdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,12 @@ Learning Dictionary Enable learning dictionary\nShows learned conversion candidates Disable learning dictionary\nDoes not show learned conversion candidates + Detect incognito mode + When an app requests no personalized learning, show the incognito icon and do not save new learning data. + Ignore no-personalized-learning requests from the input field. + Show learned candidates in incognito mode + Use already learned candidates even when incognito mode is detected. + Do not use already learned candidates while incognito mode is detected. Enable predictive conversion for user dictionary\nShows registered words as predictive candidates Disable predictive conversion for user dictionary @@ -567,6 +573,7 @@ Display tabs at the top of the candidate view to switch between "Prediction", "Conversion", and "Alphanumeric/Kana". Shortcut Toolbar Show a toolbar with useful shortcuts like switching Emoji keyboard + Show shortcuts Integrate shortcuts into candidate bar When there is no composing text and no clipboard preview or selected-text AI actions are shown, display shortcut icons inside the candidate bar. Romaji/English Switch Key diff --git a/app/src/main/res/xml/pref_common_legacy.xml b/app/src/main/res/xml/pref_common_legacy.xml index 11cd5f1f..9b4b07c1 100644 --- a/app/src/main/res/xml/pref_common_legacy.xml +++ b/app/src/main/res/xml/pref_common_legacy.xml @@ -355,6 +355,22 @@ android:summaryOn="@string/hide_candidate_password_summary_on" android:title="@string/hide_candidate_password_title" /> + + + + = + listOf(ShortcutType.SETTINGS, ShortcutType.EMOJI) + + private fun gemmaActions(): List = + listOf( + candidate( + string = "Translate", + type = GemmaTranslationManager.SELECTION_TRANSLATE_ACTION_CANDIDATE_TYPE.toByte() + ), + candidate( + string = "Prompt", + type = GemmaTranslationManager.SELECTION_PROMPT_ACTION_CANDIDATE_TYPE.toByte() + ) + ) + + private fun candidate( + string: String, + type: Byte = 1.toByte() + ): Candidate { + return Candidate( + string = string, + type = type, + length = string.length.toUByte(), + score = 0, + yomi = string + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterListUpdateTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterListUpdateTest.kt index 4d29a05b..7d3111bc 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterListUpdateTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterListUpdateTest.kt @@ -1,7 +1,10 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters +import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.os.Looper import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -40,9 +43,43 @@ class SuggestionAdapterListUpdateTest { adapter.release() } + @Test + fun incognitoQuickActionRequestsStartAnchorWithoutSuggestionListUpdate() { + val adapter = SuggestionAdapter() + adapter.setShortcutItems(listOf(ShortcutType.SETTINGS, ShortcutType.EMOJI)) + adapter.setIntegratedShortcutItemsVisibility(true) + drainMainUntil { adapter.itemCount == 2 } + + val startAnchorCount = AtomicInteger(0) + val listUpdateCount = AtomicInteger(0) + val firstAnchor = CountDownLatch(1) + adapter.onStartAnchoredContentCommitted = { + startAnchorCount.incrementAndGet() + firstAnchor.countDown() + } + adapter.onListUpdated = { + listUpdateCount.incrementAndGet() + } + + adapter.setIncognitoIcon(ColorDrawable(Color.BLACK)) + + drainMainUntil(firstAnchor) + assertTrue( + "incognito leading action should request a start anchor", + firstAnchor.await(0, TimeUnit.MILLISECONDS) + ) + assertEquals(1, startAnchorCount.get()) + assertEquals(0, listUpdateCount.get()) + adapter.release() + } + private fun drainMainUntil(latch: CountDownLatch) { + drainMainUntil { latch.count == 0L } + } + + private fun drainMainUntil(condition: () -> Boolean) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) - while (latch.count > 0 && System.nanoTime() < deadline) { + while (!condition() && System.nanoTime() < deadline) { shadowOf(Looper.getMainLooper()).idle() Thread.sleep(10) } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt new file mode 100644 index 00000000..826359e3 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt @@ -0,0 +1,53 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters + +import android.content.Context +import android.os.Looper +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SuggestionAdapterShortcutEntryClickTest { + + @Test + fun shortcutEntryClickNotifiesListener() { + val adapter = SuggestionAdapter() + adapter.setShortcutItems(listOf(ShortcutType.SETTINGS)) + adapter.setIntegratedShortcutEntryVisibility(true) + adapter.setClipboardPreview("clip") + drainMainUntilItemCount(adapter, expectedItemCount = 2) + + var clicked = false + adapter.setOnShortcutEntryClickListener { + clicked = true + } + + val context = ApplicationProvider.getApplicationContext() + val parent = FrameLayout(context) + val holder = adapter.onCreateViewHolder( + parent, + SuggestionAdapter.VIEW_TYPE_SHORTCUT_ENTRY + ) + adapter.onBindViewHolder(holder, 0) + + holder.itemView.performClick() + + assertTrue(clicked) + adapter.release() + } + + private fun drainMainUntilItemCount(adapter: SuggestionAdapter, expectedItemCount: Int) { + repeat(20) { + shadowOf(Looper.getMainLooper()).idle() + if (adapter.itemCount >= expectedItemCount) return + Thread.sleep(10) + } + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncognitoModeSettingsEntryPointTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncognitoModeSettingsEntryPointTest.kt new file mode 100644 index 00000000..e2f70511 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncognitoModeSettingsEntryPointTest.kt @@ -0,0 +1,121 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.setting + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.ime_service.ImePreferencesSnapshot +import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class IncognitoModeSettingsEntryPointTest { + + private lateinit var context: Context + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + AppPreference.init(context) + } + + @Test + fun incognitoSettingsDefaultToEnabled() { + assertTrue(AppPreference.incognito_mode_detection_preference) + assertTrue(AppPreference.show_learned_candidates_in_incognito_preference) + + val snapshot = ImePreferencesSnapshot.from(AppPreference) + + assertTrue(snapshot.incognitoModeDetectionPreference) + assertTrue(snapshot.showLearnedCandidatesInIncognitoPreference) + } + + @Test + fun incognitoSettingsUseDefaultSharedPreferencesKeys() { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + preferences.edit() + .putBoolean("incognito_mode_detection_preference", false) + .putBoolean("show_learned_candidates_in_incognito_preference", false) + .commit() + + assertFalse(AppPreference.incognito_mode_detection_preference) + assertFalse(AppPreference.show_learned_candidates_in_incognito_preference) + + AppPreference.incognito_mode_detection_preference = true + AppPreference.show_learned_candidates_in_incognito_preference = true + + assertTrue(preferences.getBoolean("incognito_mode_detection_preference", false)) + assertTrue( + preferences.getBoolean("show_learned_candidates_in_incognito_preference", false) + ) + } + + @Test + fun incognitoSettingsLiveInCommonLegacyXml() { + val commonKeys = preferenceKeys(R.xml.pref_common_legacy) + val dictionaryKeys = preferenceKeys(R.xml.pref_dictionary) + + requiredIncognitoSettingKeys.forEach { key -> + assertTrue("Missing $key from common settings XML", key in commonKeys) + assertFalse("Unexpected $key in dictionary settings XML", key in dictionaryKeys) + } + } + + @Test + fun incognitoSettingsAreSearchableInNewAndLegacySettings() { + val newHomeKeys = SettingSearchIndex.searchable(context, SettingSearchScope.NEW_HOME) + .map { it.key } + .toSet() + val legacyKeys = SettingSearchIndex.searchable(context, SettingSearchScope.LEGACY_TABS) + .map { it.key } + .toSet() + + requiredIncognitoSettingKeys.forEach { key -> + assertTrue("Missing $key from new settings search", key in newHomeKeys) + assertTrue("Missing $key from legacy settings search", key in legacyKeys) + } + } + + @Test + fun incognitoSettingsCanBeAddedFromFrequentSettingsScreen() { + val candidateKeys = SettingDestinations.frequentCandidates(context) + .map { it.key } + .toSet() + + requiredIncognitoSettingKeys.forEach { key -> + assertTrue("Missing $key from frequent setting candidates", key in candidateKeys) + } + } + + private companion object { + val requiredIncognitoSettingKeys = setOf( + "incognito_mode_detection_preference", + "show_learned_candidates_in_incognito_preference", + ) + } + + private fun preferenceKeys(xmlRes: Int): Set { + val androidNamespace = "http://schemas.android.com/apk/res/android" + val parser = context.resources.getXml(xmlRes) + return parser.use { + buildSet { + while (parser.eventType != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG) { + parser.getAttributeValue(androidNamespace, "key")?.let(::add) + } + parser.next() + } + } + } + } +} 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 d83bdf53..a1bf8084 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 @@ -2320,20 +2320,22 @@ class FlickKeyboardView @JvmOverloads constructor( val x = event.getX(existingPointerIndex) val y = event.getY(existingPointerIndex) - val cancelEvent = MotionEvent.obtain( + // A second finger starts a new key gesture; the first finger should be + // committed at its current position, not canceled. + val upEvent = MotionEvent.obtain( downTime, event.eventTime, - MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_UP, x, y, event.metaState ) - cancelEvent.offsetLocation( + upEvent.offsetLocation( -target.left.toFloat(), -target.top.toFloat() ) - target.dispatchTouchEvent(cancelEvent) - cancelEvent.recycle() + target.dispatchTouchEvent(upEvent) + upEvent.recycle() } }