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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Privacy Policy

**Effective Date:** October 18, 2025
**Last Updated:** March 16, 2026
**Last Updated:** March 18, 2026

## Introduction

Expand Down Expand Up @@ -51,6 +51,8 @@ For privacy inquiries, contact us at the email above.

**Fallback:** If your device does not have a lock screen configured, learned words are stored without encryption, and you will see a warning about this in the app.

**Viewing Learned Words:** You can view all learned words in Settings → Privacy & Data → Manage Learned Words. If your device has a lock screen configured, you will be prompted to authenticate (PIN, pattern, password, or biometric) before the list is shown. This prevents unauthorized access to your personal vocabulary.

### 1A. Word Frequency Tracking (Encrypted)

**What:** Per-word usage frequency counters stored separately from learned words.
Expand Down Expand Up @@ -311,6 +313,9 @@ Number-only fields (e.g., OTP codes, calculators) use a direct-commit mode that
### Learned Words, Word Frequencies, and Bigram Predictions
- **Retention:** Word frequency and bigram entries unused for 30 days are automatically pruned. Maximum limits are enforced (10,000 frequency entries and 50,000 bigram entries per language). Learned words are retained indefinitely until you manually delete them or uninstall the app.
- **Management Options:**
- View and manage individual learned words: Settings → Privacy & Data → Manage Learned Words (authentication required if device lock screen is set)
- Delete individual learned words: Settings → Privacy & Data → Manage Learned Words → tap delete icon (removes the word from all tables: learned words, word frequencies, and bigram predictions)
- Delete all learned words: Settings → Privacy & Data → Manage Learned Words → Delete All (removes all learned words, word frequencies, and bigram predictions)
- Export dictionary: Settings → Privacy & Data → Export Dictionary (exports `learned_words` table only; word frequencies and bigrams remain local)
- Import dictionary: Settings → Privacy & Data → Import Dictionary
- Clear all learned words: Settings → Privacy & Data → Clear Learned Words (also clears frequency and bigram data)
Expand Down Expand Up @@ -370,6 +375,7 @@ When you uninstall Urik, Android automatically deletes:
You have the right to:

1. **Access Your Data**
- View learned words: Settings → Privacy & Data → Manage Learned Words (authentication required if device lock screen is set)
- View clipboard history: Long-press symbols key on keyboard
- View custom key mappings: Settings → Layout & Input → Customize Keys
- View error logs: Settings → Privacy & Data → Export Error Logs
Expand All @@ -378,6 +384,8 @@ You have the right to:
- **Note:** Word frequency counters and bigram prediction data are used internally for suggestion ranking and cannot be viewed directly through the UI

2. **Delete Your Data**
- Delete individual learned words: Settings → Privacy & Data → Manage Learned Words → tap delete icon (completely removes the word from learned words, word frequencies, and bigram predictions)
- Delete all learned words: Settings → Privacy & Data → Manage Learned Words → Delete All
- Clear specific learned words: Long-press suggestions to remove
- Clear specific clipboard items: Long-press symbols key → tap × button
- Clear all clipboard unpinned items: Long-press symbols key → Recent tab → Delete All
Expand Down Expand Up @@ -464,7 +472,7 @@ Urik does not knowingly collect personal information from children under 13 year
- No online interactions
- No advertising or tracking

Parents can review and delete any learned words through the keyboard settings.
Parents can view and delete individual or all learned words through Settings → Privacy & Data → Manage Learned Words.

## Changes to This Privacy Policy

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometric)
implementation(libs.material)

implementation(libs.androidx.lifecycle.viewmodel.ktx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,18 @@ interface LearnedWordDao {
@Query("SELECT * FROM learned_words")
suspend fun getAllLearnedWords(): List<LearnedWord>

@Query("SELECT word_normalized FROM learned_words WHERE word = :displayWord LIMIT 1")
suspend fun findNormalizedByDisplayWord(displayWord: String): String?

@Query("SELECT DISTINCT language_tag FROM learned_words WHERE word_normalized = :normalizedWord")
suspend fun findLanguagesForWord(normalizedWord: String): List<String>

@Query("DELETE FROM learned_words")
suspend fun clearAll(): Int

@Query("DELETE FROM learned_words_fts")
suspend fun clearAllFts(): Int

@Transaction
suspend fun importWordWithMerge(word: LearnedWord) {
val existing = findExactWord(word.languageTag, word.wordNormalized)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ interface UserWordBigramDao {
)
suspend fun getTopBigrams(languageTag: String, limit: Int = 100): List<UserWordBigram>

@Query(
"""
DELETE FROM user_word_bigram
WHERE word_a_normalized = :normalizedWord
OR word_b_normalized = :normalizedWord
"""
)
suspend fun deleteByWord(normalizedWord: String): Int

@Query("DELETE FROM user_word_bigram WHERE language_tag = :languageTag")
suspend fun clearLanguage(languageTag: String): Int

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ interface UserWordFrequencyDao {
)
suspend fun incrementFrequencyBy(languageTag: String, wordNormalized: String, amount: Int, lastUsed: Long)

@Query("DELETE FROM user_word_frequency WHERE word_normalized = :normalizedWord")
suspend fun deleteByNormalizedWord(normalizedWord: String): Int

@Query("DELETE FROM user_word_frequency WHERE language_tag = :languageTag")
suspend fun clearLanguage(languageTag: String): Int

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,18 @@ object KeyboardModule {
@Singleton
fun provideWordLearningEngine(
learnedWordDao: LearnedWordDao,
userWordFrequencyDao: UserWordFrequencyDao,
userWordBigramDao: UserWordBigramDao,
database: KeyboardDatabase,
languageManager: LanguageManager,
wordNormalizer: WordNormalizer,
settingsRepository: SettingsRepository,
cacheMemoryManager: CacheMemoryManager
): WordLearningEngine = WordLearningEngine(
learnedWordDao,
userWordFrequencyDao,
userWordBigramDao,
database,
languageManager,
wordNormalizer,
settingsRepository,
Expand Down
91 changes: 91 additions & 0 deletions app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import android.database.sqlite.SQLiteDatabaseCorruptException
import android.database.sqlite.SQLiteDatabaseLockedException
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteFullException
import androidx.room.withTransaction
import com.urik.keyboard.data.database.KeyboardDatabase
import com.urik.keyboard.data.database.LearnedWord
import com.urik.keyboard.data.database.LearnedWordDao
import com.urik.keyboard.data.database.UserWordBigramDao
import com.urik.keyboard.data.database.UserWordFrequencyDao
import com.urik.keyboard.data.database.WordSource
import com.urik.keyboard.settings.KeyboardSettings
import com.urik.keyboard.settings.SettingsRepository
Expand Down Expand Up @@ -63,6 +67,9 @@ class WordLearningEngine
@Inject
constructor(
private val learnedWordDao: LearnedWordDao,
private val userWordFrequencyDao: UserWordFrequencyDao,
private val userWordBigramDao: UserWordBigramDao,
private val database: KeyboardDatabase,
private val languageManager: LanguageManager,
private val wordNormalizer: WordNormalizer,
settingsRepository: SettingsRepository,
Expand Down Expand Up @@ -962,6 +969,90 @@ constructor(
}
}

suspend fun getAllLearnedWordsUnified(): Result<List<String>> = withContext(ioDispatcher) {
try {
val learnedWords = learnedWordDao.getAllLearnedWords()

val displayWords = learnedWords
.map { it.word }
.distinct()
.sortedWith(String.CASE_INSENSITIVE_ORDER)

Result.success(displayWords)
} catch (e: SQLiteException) {
ErrorLogger.logException(
component = "WordLearningEngine",
severity = ErrorLogger.Severity.HIGH,
exception = e,
context = mapOf("operation" to "getAllLearnedWordsUnified")
)
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}

suspend fun deleteWordCompletely(word: String): Result<Unit> = withContext(ioDispatcher) {
try {
val normalized = learnedWordDao.findNormalizedByDisplayWord(word)
?: wordNormalizer.normalize(word, languageManager.currentLanguage.value)

database.withTransaction {
val languages = learnedWordDao.findLanguagesForWord(normalized)
languages.forEach { lang ->
learnedWordDao.removeWordComplete(lang, normalized)
}
userWordFrequencyDao.deleteByNormalizedWord(normalized)
userWordBigramDao.deleteByWord(normalized)
}

learnedWordsCache.invalidateAll()
hotFrequencyBuffer.clear()
swipeWordsCache = emptyList()
swipeWordsCacheLanguage = ""

Result.success(Unit)
} catch (e: SQLiteException) {
ErrorLogger.logException(
component = "WordLearningEngine",
severity = ErrorLogger.Severity.HIGH,
exception = e,
context = mapOf("operation" to "deleteWordCompletely")
)
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}

suspend fun deleteAllWordsCompletely(): Result<Unit> = withContext(ioDispatcher) {
try {
database.withTransaction {
learnedWordDao.clearAll()
learnedWordDao.clearAllFts()
userWordFrequencyDao.clearAll()
userWordBigramDao.clearAll()
}

learnedWordsCache.invalidateAll()
hotFrequencyBuffer.clear()
swipeWordsCache = emptyList()
swipeWordsCacheLanguage = ""

Result.success(Unit)
} catch (e: SQLiteException) {
ErrorLogger.logException(
component = "WordLearningEngine",
severity = ErrorLogger.Severity.CRITICAL,
exception = e,
context = mapOf("operation" to "deleteAllWordsCompletely")
)
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}

private companion object {
const val LEARNED_WORDS_CACHE_SIZE = 100
const val HOT_BUFFER_MAX_SIZE = 1000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.urik.keyboard.settings.appearance.AppearanceFragment
import com.urik.keyboard.settings.autocorrection.AutoCorrectionFragment
import com.urik.keyboard.settings.languages.LanguagesFragment
import com.urik.keyboard.settings.layoutinput.LayoutInputFragment
import com.urik.keyboard.settings.learnedwords.LearnedWordsFragment
import com.urik.keyboard.settings.privacydata.PrivacyDataFragment
import com.urik.keyboard.settings.typingbehavior.TypingBehaviorFragment
import dagger.hilt.android.AndroidEntryPoint
Expand Down Expand Up @@ -63,6 +64,7 @@ class SettingsActivity : AppCompatActivity() {
is LayoutInputFragment -> getString(R.string.layout_settings_title)
is AppearanceFragment -> getString(R.string.appearance_settings_title)
is PrivacyDataFragment -> getString(R.string.privacy_settings_title)
is LearnedWordsFragment -> getString(R.string.learned_words_title)
else -> getString(R.string.settings_title)
}
supportActionBar?.title = title
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/urik/keyboard/settings/SettingsEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ sealed interface SettingsEvent {
data object PauseOnMisspelledToggleFailed : Error

data object AutocorrectionToggleFailed : Error

data object DeleteWordFailed : Error

data object DeleteAllWordsFailed : Error
}

/**
Expand All @@ -76,5 +80,9 @@ sealed interface SettingsEvent {
data class DictionaryExported(val wordCount: Int) : Success

data class DictionaryImported(val newWords: Int, val updatedWords: Int) : Success

data object WordDeleted : Success

data object AllWordsDeleted : Success
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ class SettingsEventHandler(private val context: Context) {
context.getString(R.string.error_update_autocorrection)
}

is SettingsEvent.Error.DeleteWordFailed -> {
context.getString(R.string.error_delete_word)
}

is SettingsEvent.Error.DeleteAllWordsFailed -> {
context.getString(R.string.error_delete_all_words)
}

is SettingsEvent.Success.LearnedWordsCleared -> {
context.getString(R.string.success_learned_words_cleared)
}
Expand All @@ -146,6 +154,14 @@ class SettingsEventHandler(private val context: Context) {
event.updatedWords
)
}

is SettingsEvent.Success.WordDeleted -> {
context.getString(R.string.success_word_deleted)
}

is SettingsEvent.Success.AllWordsDeleted -> {
context.getString(R.string.success_all_words_deleted)
}
}

Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
Expand Down
Loading