diff --git a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt index 09e31f4f1..0d3d917a0 100644 --- a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/KeyboardTest.kt @@ -106,8 +106,8 @@ class KeyboardTest { suggestionHandler.processLinguisticSuggestions("in") - verify { conjugateBtn.text = match { it.isNotEmpty() } } - verify { pluralBtn.text = match { it.isNotEmpty() } } - verify { translateBtn.text = match { it.isNotEmpty() } } + verify(timeout = 2000) { conjugateBtn.text = match { it.isNotEmpty() } } + verify(timeout = 2000) { pluralBtn.text = match { it.isNotEmpty() } } + verify(timeout = 2000) { translateBtn.text = match { it.isNotEmpty() } } } } diff --git a/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt index c984cadbe..e0a85a91c 100644 --- a/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/ui/screens/settings/SettingsScreenInstrumentedTest.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -256,6 +257,14 @@ class SettingsScreenInstrumentedTest { composeTestRule.waitForIdle() + // Click OK on the warning dialog if it appeared (since it goes against system theme) + composeTestRule.onAllNodesWithText("OK").apply { + if (fetchSemanticsNodes().isNotEmpty()) { + get(0).performClick() + composeTestRule.waitForIdle() + } + } + verify(timeout = 3000) { mockViewModelSpy.setLightDarkMode(true) } verify(timeout = 3000) { onDarkModeChangeMock(true) } } diff --git a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt index 704ca9375..818e8feb4 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyHandler.kt @@ -50,6 +50,10 @@ class KeyHandler( resetShiftIfNeeded(code) + if (code != KeyboardBase.KEYCODE_SHIFT && code != KeyboardBase.KEYCODE_MODE_CHANGE) { + ime.hideClipboardSuggestionChip() + } + val previousWasLastKeySpace = wasLastKeySpace if (code != KeyboardBase.KEYCODE_SPACE && code != KeyboardBase.KEYCODE_ENTER) { suggestionHandler.clearLinguisticSuggestions() @@ -113,6 +117,10 @@ class KeyHandler( handleCurrencyKey(language) true } + KeyboardBase.KEYCODE_CLIPBOARD -> { + ime.openClipboardPanel() + true + } else -> { handleDefaultKey(code) true diff --git a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt index 102bf5986..84fbbd492 100644 --- a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt +++ b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt @@ -73,6 +73,8 @@ class KeyboardUIManager( fun processLinguisticSuggestions(word: String) fun isNumericKeyboardActive(): Boolean + + fun onClipboardSuggestionClicked() } var keyboardView: KeyboardView = binding.keyboardView @@ -106,8 +108,14 @@ class KeyboardUIManager( } private fun setupClickListeners() { - binding.scribeKeyOptions.setOnClickListener { listener.onScribeKeyOptionsClicked() } - binding.scribeKeyToolbar.setOnClickListener { listener.onScribeKeyToolbarClicked() } + binding.scribeKeyOptions.setOnClickListener { + hideClipboardSuggestionChip() + listener.onScribeKeyOptionsClicked() + } + binding.scribeKeyToolbar.setOnClickListener { + hideClipboardSuggestionChip() + listener.onScribeKeyToolbarClicked() + } binding.translateBtn.setOnClickListener { listener.onTranslateClicked() } binding.conjugateBtn.setOnClickListener { listener.onConjugateClicked() } @@ -115,6 +123,8 @@ class KeyboardUIManager( binding.scribeKeyClose.setOnClickListener { listener.onCloseClicked() } + binding.clipboardSuggestionChip.setOnClickListener { listener.onClipboardSuggestionClicked() } + // Info button listener for INVALID state. binding.ivInfo.setOnClickListener { showInvalidInfo() } } @@ -940,4 +950,57 @@ class KeyboardUIManager( ) } } + + fun showClipboardSuggestionChip(clipText: String) { + // Truncate long clips to display in suggestion bar cleanly + val truncatedText = + if (clipText.length > 25) { + clipText.take(22) + "..." + } else { + clipText + } + binding.clipboardSuggestionChip.text = truncatedText + binding.clipboardSuggestionChip.visibility = View.VISIBLE + + // Hide default suggestion buttons & separators + binding.translateBtn.visibility = View.GONE + binding.conjugateBtn.visibility = View.GONE + binding.pluralBtn.visibility = View.GONE + binding.separator2.visibility = View.GONE + binding.separator3.visibility = View.GONE + + // Hide emoji buttons if any + binding.emojiBtnPhone1?.visibility = View.GONE + binding.emojiBtnPhone2?.visibility = View.GONE + binding.emojiBtnTablet1?.visibility = View.GONE + binding.emojiBtnTablet2?.visibility = View.GONE + binding.emojiBtnTablet3?.visibility = View.GONE + binding.separator4.visibility = View.GONE + binding.separator5.visibility = View.GONE + binding.separator6.visibility = View.GONE + } + + fun hideClipboardSuggestionChip() { + if (binding.clipboardSuggestionChip.visibility == View.VISIBLE) { + binding.clipboardSuggestionChip.visibility = View.GONE + // Restore visibility of default buttons + binding.translateBtn.visibility = View.VISIBLE + binding.conjugateBtn.visibility = View.VISIBLE + binding.pluralBtn.visibility = View.VISIBLE + binding.separator2.visibility = View.VISIBLE + binding.separator3.visibility = View.VISIBLE + } + } + + fun showClipboardPanel() { + binding.clipboardPanelHolder.visibility = View.VISIBLE + keyboardView.visibility = View.INVISIBLE + binding.commandOptionsBar.visibility = View.INVISIBLE + } + + fun hideClipboardPanel() { + binding.clipboardPanelHolder.visibility = View.GONE + keyboardView.visibility = View.VISIBLE + binding.commandOptionsBar.visibility = View.VISIBLE + } } diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index 3e901ee61..76a48f6a8 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -58,11 +58,13 @@ import be.scri.helpers.SHIFT_OFF import be.scri.helpers.SHIFT_ON_ONE_CHAR import be.scri.helpers.SHIFT_ON_PERMANENT import be.scri.helpers.SuggestionHandler +import be.scri.helpers.clipboard.ClipboardMonitor import be.scri.helpers.data.AutocompletionDataManager import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG import be.scri.helpers.ui.KeyboardUIManager import be.scri.models.ScribeState import be.scri.views.KeyboardView +import kotlinx.coroutines.launch import java.util.Locale private const val DATA_SIZE_2 = 2 @@ -115,6 +117,11 @@ abstract class GeneralKeyboardIME( internal val binding: InputMethodViewBinding get() = uiManager.binding + // Clipboard Variables + internal var hasNewClip: Boolean = false + internal var latestClipText: String? = null + private lateinit var clipboardMonitor: ClipboardMonitor + // MARK: State Variables internal var isSingularAndPlural: Boolean = false @@ -211,6 +218,14 @@ abstract class GeneralKeyboardIME( suggestionHandler = SuggestionHandler(this) autocompletionManager = dbManagers.autocompletionManager autocompletionHandler = AutocompletionHandler(this) + clipboardMonitor = + ClipboardMonitor(this) { text -> + latestClipText = text + hasNewClip = true + if (currentState == ScribeState.IDLE && this::uiManager.isInitialized) { + uiManager.showClipboardSuggestionChip(text) + } + } } override fun onDestroy() { @@ -339,6 +354,9 @@ abstract class GeneralKeyboardIME( restarting: Boolean, ) { super.onStartInputView(editorInfo, restarting) + if (this::clipboardMonitor.isInitialized) { + clipboardMonitor.startMonitoring() + } emojiAutoSuggestionEnabled = getIsEmojiSuggestionsEnabled(applicationContext, language) autoSuggestEmojis = null suggestionHandler.clearAllSuggestionsAndHideButtonUI() @@ -416,6 +434,9 @@ abstract class GeneralKeyboardIME( */ override fun onFinishInputView(finishingInput: Boolean) { super.onFinishInputView(finishingInput) + if (this::clipboardMonitor.isInitialized) { + clipboardMonitor.stopMonitoring() + } moveToIdleState() } @@ -510,6 +531,7 @@ abstract class GeneralKeyboardIME( KeyboardBase.KEYCODE_ENTER -> handleKeycodeEnter() KeyboardBase.KEYCODE_MODE_CHANGE -> handleModeChange(keyboardMode, keyboardView, this) + KeyboardBase.KEYCODE_CLIPBOARD -> openClipboardPanel() else -> { if (KeyboardBase.SCRIBE_VIEW_KEYS.contains(code)) { val keyLabel = keyboardView?.getKeyLabel(code) @@ -1950,8 +1972,89 @@ abstract class GeneralKeyboardIME( emojis: MutableList?, ) = uiManager.updateEmojiSuggestion(currentState, enabled, emojis) - /** - * Disables all auto-suggestions and resets the suggestion buttons to their default, inactive state. - */ fun disableAutoSuggest() = uiManager.disableAutoSuggest(language) + + override fun onClipboardSuggestionClicked() { + latestClipText?.let { text -> + currentInputConnection?.commitText(text, 1) + } + hideClipboardSuggestionChip() + } + + fun hideClipboardSuggestionChip() { + hasNewClip = false + latestClipText = null + if (this::uiManager.isInitialized) { + uiManager.hideClipboardSuggestionChip() + } + } + + // MARK: Clipboard Panel + + private var clipboardAdapter: be.scri.helpers.clipboard.ClipboardAdapter? = null + private val clipboardRepository by lazy { + be.scri.helpers.clipboard + .ClipboardRepository(this) + } + + fun openClipboardPanel() { + if (!this::uiManager.isInitialized) return + uiManager.showClipboardPanel() + + val recyclerView = binding.clipboardItemsList + val emptyText = binding.clipboardEmptyText + + // Always rebuild the adapter so it picks up current theme colors + clipboardAdapter = + be.scri.helpers.clipboard.ClipboardAdapter( + items = emptyList(), + onItemClick = { item -> + currentInputConnection?.commitText(item.text, 1) + closeClipboardPanel() + }, + onItemDelete = { item -> + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + clipboardRepository.deleteItem(item.id) + refreshClipboardPanel() + } + }, + onItemPinToggle = { item -> + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + clipboardRepository.togglePin(item.id, item.isPinned) + refreshClipboardPanel() + } + }, + ) + recyclerView.adapter = clipboardAdapter + recyclerView.layoutManager = androidx.recyclerview.widget.GridLayoutManager(this, 2) + + // Set up close and clear-all buttons + binding.clipboardPanelClose.setOnClickListener { closeClipboardPanel() } + binding.clipboardClearAll.setOnClickListener { + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + clipboardRepository.clearAll() + refreshClipboardPanel() + } + } + + // Load items + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + val items = clipboardRepository.getAllItems() + clipboardAdapter?.updateItems(items) + emptyText.visibility = if (items.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + recyclerView.visibility = if (items.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + } + } + + fun closeClipboardPanel() { + if (!this::uiManager.isInitialized) return + uiManager.hideClipboardPanel() + } + + private suspend fun refreshClipboardPanel() { + val items = clipboardRepository.getAllItems() + clipboardAdapter?.updateItems(items) + binding.clipboardEmptyText.visibility = if (items.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + binding.clipboardItemsList.visibility = if (items.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + } } diff --git a/app/src/main/java/be/scri/helpers/KeyboardBase.kt b/app/src/main/java/be/scri/helpers/KeyboardBase.kt index 4af94453d..89d68df2a 100644 --- a/app/src/main/java/be/scri/helpers/KeyboardBase.kt +++ b/app/src/main/java/be/scri/helpers/KeyboardBase.kt @@ -88,6 +88,7 @@ class KeyboardBase { const val KEYCODE_CAPS_LOCK = -50 const val KEYCODE_LEFT_ARROW = -55 const val KEYCODE_RIGHT_ARROW = -56 + const val KEYCODE_CLIPBOARD = -60 const val SHIFT_OFF = 0 const val SHIFT_ON = 1 const val SHIFT_ON_PERMANENT = 2 @@ -658,6 +659,43 @@ class KeyboardBase { } } + // Dynamically add clipboard key on bottom row of letters keyboard + try { + if (currentKeyboardMode == keyboardLettersMode && mKeys != null) { + val spaceKey = mKeys!!.find { it?.code == 32 } + val commaKey = mKeys!!.find { it?.code == ','.code } + val row = mRows.lastOrNull() + if (spaceKey != null && commaKey != null && row != null) { + val clipWidth = (mDisplayWidth * 0.10).toInt() + + val clipKey = Key(row) + clipKey.code = KEYCODE_CLIPBOARD + clipKey.width = clipWidth + clipKey.height = spaceKey.height + clipKey.gap = spaceKey.gap + clipKey.icon = context.resources.getDrawable(R.drawable.ic_clipboard_vector, context.theme) + clipKey.icon?.setBounds(0, 0, clipKey.icon!!.intrinsicWidth, clipKey.icon!!.intrinsicHeight) + + spaceKey.width = spaceKey.width - clipWidth - clipKey.gap + + val commaIdxInList = mKeys!!.indexOf(commaKey) + val commaIdxInRow = row.mKeys.indexOf(commaKey) + + if (commaIdxInList != -1 && commaIdxInRow != -1) { + clipKey.x = commaKey.x + commaKey.width + commaKey.gap + clipKey.y = commaKey.y + + spaceKey.x = clipKey.x + clipKey.width + clipKey.gap + + mKeys!!.add(commaIdxInList + 1, clipKey) + row.mKeys.add(commaIdxInRow + 1, clipKey) + } + } + } + } catch (e: Exception) { + Log.e("KeyboardBase", "Error dynamically adding clipboard key", e) + } + mHeight = y } diff --git a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt index 1427bb8a0..6a2451093 100644 --- a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt +++ b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt @@ -2,12 +2,10 @@ package be.scri.helpers -import android.app.UiModeManager import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.res.Configuration import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity.UI_MODE_SERVICE import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit @@ -304,13 +302,7 @@ object PreferencesHelper { * @return The dark mode setting as an integer value (AppCompatDelegate.MODE_NIGHT_YES or MODE_NIGHT_NO). */ fun getUserDarkModePreference(context: Context): Int { - val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, Context.MODE_PRIVATE) - val uiModeManager = context.getSystemService(UI_MODE_SERVICE) as UiModeManager - val isSystemDarkTheme = uiModeManager.nightMode == UiModeManager.MODE_NIGHT_YES - val isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkTheme) - if (!sharedPref.contains("dark_mode")) { - setLightDarkModePreference(context, isUserDarkMode) - } + val isUserDarkMode = getIsDarkModeOrNot(context) return if (isUserDarkMode) { AppCompatDelegate.MODE_NIGHT_YES } else { @@ -427,7 +419,19 @@ object PreferencesHelper { val sharedPref = context.getSharedPreferences(SCRIBE_PREFS, MODE_PRIVATE) val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK val isSystemDarkMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES - val isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) + + val lastSystemTheme = sharedPref.getBoolean("last_system_dark_mode", isSystemDarkMode) + var isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) + + if (!sharedPref.contains("last_system_dark_mode") || lastSystemTheme != isSystemDarkMode) { + isUserDarkMode = isSystemDarkMode + sharedPref + .edit() + .putBoolean("dark_mode", isUserDarkMode) + .putBoolean("last_system_dark_mode", isSystemDarkMode) + .apply() + } + return isUserDarkMode } diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt new file mode 100644 index 000000000..15071d426 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardAdapter.kt @@ -0,0 +1,73 @@ +package be.scri.helpers.clipboard + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import be.scri.R + +class ClipboardAdapter( + private var items: List, + private val onItemClick: (ClipboardItem) -> Unit, + private val onItemDelete: (ClipboardItem) -> Unit, + private val onItemPinToggle: (ClipboardItem) -> Unit, +) : RecyclerView.Adapter() { + class ViewHolder( + view: View, + ) : RecyclerView.ViewHolder(view) { + val clipText: TextView = view.findViewById(R.id.clip_text) + val pinIcon: ImageView = view.findViewById(R.id.pin_icon) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val view = + LayoutInflater + .from(parent.context) + .inflate(R.layout.clipboard_item, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val item = items[position] + holder.clipText.text = item.text + // Show clipboard icon on every item (matches Figma design) + holder.pinIcon.visibility = View.VISIBLE + + holder.itemView.setOnClickListener { + onItemClick(item) + } + + holder.itemView.setOnLongClickListener { view -> + val context = view.context + val popup = PopupMenu(context, view) + popup.menu.add(if (item.isPinned) "Unpin" else "Pin") + popup.menu.add("Delete") + + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.title) { + "Pin", "Unpin" -> onItemPinToggle(item) + "Delete" -> onItemDelete(item) + } + true + } + popup.show() + true + } + } + + override fun getItemCount(): Int = items.size + + fun updateItems(newItems: List) { + items = newItems + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardDao.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardDao.kt new file mode 100644 index 000000000..12c90f21a --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardDao.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.clipboard + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface ClipboardDao { + @Query("SELECT * FROM clipboard_items ORDER BY isPinned DESC, timestamp DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM clipboard_items WHERE text = :text LIMIT 1") + suspend fun getByText(text: String): ClipboardItem? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: ClipboardItem): Long + + @Query("DELETE FROM clipboard_items WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM clipboard_items") + suspend fun deleteAll() + + @Query("UPDATE clipboard_items SET isPinned = :pinned WHERE id = :id") + suspend fun updatePinned( + id: Long, + pinned: Boolean, + ) + + @Query("SELECT COUNT(*) FROM clipboard_items WHERE isPinned = 0") + suspend fun getUnpinnedCount(): Int + + @Query("SELECT * FROM clipboard_items WHERE isPinned = 0 ORDER BY timestamp ASC LIMIT 1") + suspend fun getOldestUnpinned(): ClipboardItem? + + @Query("DELETE FROM clipboard_items WHERE isPinned = 0 AND timestamp < :expiryTime") + suspend fun deleteExpired(expiryTime: Long) + + @Query("UPDATE clipboard_items SET timestamp = :timestamp WHERE id = :id") + suspend fun updateTimestamp( + id: Long, + timestamp: Long, + ) +} diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardDatabase.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardDatabase.kt new file mode 100644 index 000000000..ba3586926 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardDatabase.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.clipboard + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [ClipboardItem::class], version = 1, exportSchema = false) +abstract class ClipboardDatabase : RoomDatabase() { + abstract fun clipboardDao(): ClipboardDao + + companion object { + @Volatile + private var instance: ClipboardDatabase? = null + + fun getDatabase(context: Context): ClipboardDatabase = + instance ?: synchronized(this) { + val newInstance = + Room + .databaseBuilder( + context.applicationContext, + ClipboardDatabase::class.java, + "scribe_clipboard_db", + ).build() + instance = newInstance + newInstance + } + } +} diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardItem.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardItem.kt new file mode 100644 index 000000000..0dde0600a --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardItem.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.clipboard + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "clipboard_items") +data class ClipboardItem( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val text: String, + val timestamp: Long = System.currentTimeMillis(), + val isPinned: Boolean = false, +) diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardMonitor.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardMonitor.kt new file mode 100644 index 000000000..b5bc0b2a4 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardMonitor.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.clipboard + +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ClipboardMonitor( + private val context: Context, + private val onNewClip: (String) -> Unit, +) { + private val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + private val repository = ClipboardRepository(context) + private val scope = CoroutineScope(Dispatchers.Main) + + // Debounce: track last processed text + time to prevent double-fires on some devices + private var lastProcessedText: String? = null + private var lastProcessedTime: Long = 0L + private val debounceMs = 1000L + + private val listener = + ClipboardManager.OnPrimaryClipChangedListener { + processCurrentClip() + } + + fun startMonitoring() { + try { + // Always remove first to prevent duplicate registrations. + // Android's ClipboardManager does not deduplicate listeners, so calling + // addPrimaryClipChangedListener twice stacks up two callbacks per copy event. + clipboardManager.removePrimaryClipChangedListener(listener) + clipboardManager.addPrimaryClipChangedListener(listener) + } catch (e: Exception) { + Log.e("ClipboardMonitor", "Failed to add primary clip changed listener", e) + } + } + + fun stopMonitoring() { + try { + clipboardManager.removePrimaryClipChangedListener(listener) + } catch (e: Exception) { + Log.e("ClipboardMonitor", "Failed to remove primary clip changed listener", e) + } + } + + private fun processCurrentClip() { + val clip = clipboardManager.primaryClip ?: return + if (clip.itemCount == 0) return + + val description = clipboardManager.primaryClipDescription + + // Skip sensitive content (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (description?.extras?.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE) == true) { + return + } + } + + // Support plain text only + val text = clip.getItemAt(0).text?.toString() ?: return + if (text.isBlank()) return + + // Debounce: skip if the same text was processed within debounceMs + val now = System.currentTimeMillis() + if (text == lastProcessedText && now - lastProcessedTime < debounceMs) return + lastProcessedText = text + lastProcessedTime = now + + scope.launch { + repository.insertItem(text) + onNewClip(text) + } + } +} diff --git a/app/src/main/java/be/scri/helpers/clipboard/ClipboardRepository.kt b/app/src/main/java/be/scri/helpers/clipboard/ClipboardRepository.kt new file mode 100644 index 000000000..89ba83592 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/clipboard/ClipboardRepository.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.clipboard + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ClipboardRepository( + context: Context, +) { + private val clipboardDao = ClipboardDatabase.getDatabase(context).clipboardDao() + + companion object { + private const val MAX_ITEMS = 25 + } + + private suspend fun pruneExpiredItems() { + val oneHourAgo = System.currentTimeMillis() - 3600000L + clipboardDao.deleteExpired(oneHourAgo) + } + + suspend fun getAllItems(): List = + withContext(Dispatchers.IO) { + pruneExpiredItems() + clipboardDao.getAll() + } + + suspend fun insertItem(text: String): Long = + withContext(Dispatchers.IO) { + pruneExpiredItems() + if (text.isBlank()) return@withContext -1L + + // Check if item already exists — just bump its timestamp, don't re-insert + val existing = clipboardDao.getByText(text) + if (existing != null) { + clipboardDao.updateTimestamp(existing.id, System.currentTimeMillis()) + return@withContext existing.id + } + + // Limit size by evicting oldest unpinned if total count exceeds MAX_ITEMS + val allItems = clipboardDao.getAll() + if (allItems.size >= MAX_ITEMS) { + val oldestUnpinned = clipboardDao.getOldestUnpinned() + if (oldestUnpinned != null) { + clipboardDao.deleteById(oldestUnpinned.id) + } + } + + val newItem = ClipboardItem(text = text) + clipboardDao.insert(newItem) + } + + suspend fun deleteItem(id: Long) = + withContext(Dispatchers.IO) { + clipboardDao.deleteById(id) + } + + suspend fun togglePin( + id: Long, + isPinned: Boolean, + ) = withContext(Dispatchers.IO) { + clipboardDao.updatePinned(id, !isPinned) + } + + suspend fun clearAll() = + withContext(Dispatchers.IO) { + clipboardDao.deleteAll() + } +} diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt index 455853f4f..e19360995 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -52,6 +53,10 @@ fun SettingsScreen( val isUserDarkMode by viewModel.isUserDarkMode.collectAsState() val isIncreaseTextSize by viewModel.isIncreaseTextSize.collectAsState() + val isSystemDarkMode = androidx.compose.foundation.isSystemInDarkTheme() + var showThemeWarningDialog by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + var pendingDarkMode by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current // Observe lifecycle events. @@ -85,8 +90,13 @@ fun SettingsScreen( desc = R.string.i18n_app_settings_menu_app_color_mode_description, state = isUserDarkMode, onToggle = { newDarkMode -> - viewModel.setLightDarkMode(newDarkMode) - onDarkModeChange(newDarkMode) + if (newDarkMode != isSystemDarkMode) { + pendingDarkMode = newDarkMode + showThemeWarningDialog = true + } else { + viewModel.setLightDarkMode(newDarkMode) + onDarkModeChange(newDarkMode) + } }, ), ScribeItem.SwitchItem( @@ -146,5 +156,31 @@ fun SettingsScreen( item { Spacer(modifier = Modifier.height(10.dp)) } } + + if (showThemeWarningDialog) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showThemeWarningDialog = false }, + title = { androidx.compose.material3.Text(text = stringResource(R.string.i18n_theme_warning_title)) }, + text = { androidx.compose.material3.Text(text = stringResource(R.string.i18n_theme_warning_message)) }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { + viewModel.setLightDarkMode(pendingDarkMode) + onDarkModeChange(pendingDarkMode) + showThemeWarningDialog = false + }, + ) { + androidx.compose.material3.Text(text = stringResource(R.string.i18n_ok)) + } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { showThemeWarningDialog = false }, + ) { + androidx.compose.material3.Text(text = stringResource(R.string.i18n_cancel)) + } + }, + ) + } } } diff --git a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt index 6656c6939..8e22d04d6 100644 --- a/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/settings/SettingsViewModel.kt @@ -29,7 +29,11 @@ class SettingsViewModel( MutableStateFlow(sharedPrefs.getBoolean("show_popup_on_keypress", false)) val popupOnKeypress: StateFlow = _popupOnKeypress - private val _isUserDarkMode = MutableStateFlow(sharedPrefs.getBoolean("dark_mode", false)) + private val _isUserDarkMode = + MutableStateFlow( + be.scri.helpers.PreferencesHelper + .getIsDarkModeOrNot(context), + ) val isUserDarkMode: StateFlow = _isUserDarkMode private val _holdForAltKeys = MutableStateFlow(sharedPrefs.getBoolean("hold_for_alt_keys", false)) diff --git a/app/src/main/java/be/scri/views/KeyboardView.kt b/app/src/main/java/be/scri/views/KeyboardView.kt index 0e959a38a..01995b2a8 100644 --- a/app/src/main/java/be/scri/views/KeyboardView.kt +++ b/app/src/main/java/be/scri/views/KeyboardView.kt @@ -4,7 +4,6 @@ package be.scri.views import android.annotation.SuppressLint import android.content.Context -import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -598,10 +597,9 @@ class KeyboardView mBackgroundColor } - val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val isSystemDarkMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES - val isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) + val isUserDarkMode = + be.scri.helpers.PreferencesHelper + .getIsDarkModeOrNot(context) val miniKeyboardBackgroundColor = resources.getColor( @@ -814,10 +812,9 @@ class KeyboardView canvas!!.clipRect(mDirtyRect) val paint = mPaint val keys = mKeys - val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val isSystemDarkMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES - val isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) + val isUserDarkMode = + be.scri.helpers.PreferencesHelper + .getIsDarkModeOrNot(context) val keyBackgroundColor = if (isUserDarkMode) { Color.DKGRAY @@ -1140,7 +1137,7 @@ class KeyboardView } key.icon = resources.getDrawable(drawableId) key.icon!!.applyColorFilter(mTextColor) - } else if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_TAB) { + } else if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_TAB || code == KeyboardBase.KEYCODE_CLIPBOARD) { key.icon!!.applyColorFilter(mTextColor) } @@ -1486,10 +1483,9 @@ class KeyboardView .findViewById(R.id.mini_keyboard_view) as KeyboardView } - val sharedPref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - val isSystemDarkMode = currentNightMode == Configuration.UI_MODE_NIGHT_YES - val isUserDarkMode = sharedPref.getBoolean("dark_mode", isSystemDarkMode) + val isUserDarkMode = + be.scri.helpers.PreferencesHelper + .getIsDarkModeOrNot(context) val miniKeyboardBackgroundColor = resources.getColor( diff --git a/app/src/main/res/drawable/clipboard_item_bg.xml b/app/src/main/res/drawable/clipboard_item_bg.xml new file mode 100644 index 000000000..bf0a54280 --- /dev/null +++ b/app/src/main/res/drawable/clipboard_item_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_clipboard_vector.xml b/app/src/main/res/drawable/ic_clipboard_vector.xml index 7fc46c5f0..0ade05363 100644 --- a/app/src/main/res/drawable/ic_clipboard_vector.xml +++ b/app/src/main/res/drawable/ic_clipboard_vector.xml @@ -1,4 +1,10 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_copy_vector.xml b/app/src/main/res/drawable/ic_copy_vector.xml new file mode 100644 index 000000000..a06198b0d --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_vector.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_delete_vector.xml b/app/src/main/res/drawable/ic_delete_vector.xml index 7fc46c5f0..c224bd5e8 100644 --- a/app/src/main/res/drawable/ic_delete_vector.xml +++ b/app/src/main/res/drawable/ic_delete_vector.xml @@ -1,4 +1,10 @@ - - - + + + diff --git a/app/src/main/res/layout/clipboard_item.xml b/app/src/main/res/layout/clipboard_item.xml new file mode 100644 index 000000000..07d1cacaa --- /dev/null +++ b/app/src/main/res/layout/clipboard_item.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/input_method_view.xml b/app/src/main/res/layout/input_method_view.xml index f79cf8133..c26728b58 100644 --- a/app/src/main/res/layout/input_method_view.xml +++ b/app/src/main/res/layout/input_method_view.xml @@ -155,6 +155,7 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/separator_1" app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -406,6 +407,25 @@ app:layout_constraintStart_toEndOf="@+id/emoji_space_tablet_2" app:layout_constraintTop_toTopOf="@+id/plural_btn" /> +