Skip to content
Open
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
36 changes: 18 additions & 18 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build EXP Mango

on:
push:
branches: [main]
branches: ["*"]
workflow_dispatch:

concurrency:
Expand All @@ -19,8 +19,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Java 21
uses: actions/setup-java@v4
Expand All @@ -33,11 +31,14 @@ jobs:
with:
packages: platform-tools

- name: Install Android 36 SDK
run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Install Android 35 SDK
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"

- name: Offline & Privacy Audit
run: bash scripts/audit-offline.sh

- name: Prepare EXP Mango source
run: bash scripts/prepare-exp-mango.sh
- name: Run Unit Tests
run: ./gradlew test --stacktrace

- name: Check signing availability
id: signing
Expand All @@ -60,27 +61,25 @@ jobs:
run: |
echo "$KEYSTORE_BASE64" | base64 -d > release.keystore

- name: Build signed EXP Mango Lite
- name: Build EXP Mango Release
if: steps.signing.outputs.available == 'true'
env:
RELEASE_STORE_FILE: release.keystore
RELEASE_STORE_PASSWORD: ${{ secrets.EXP_MANGO_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.EXP_MANGO_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.EXP_MANGO_KEY_PASSWORD }}
run: |
cd wmkeyboard
./gradlew assembleLiteEnRelease -Pwmkb.noInternet=true -Pkotlin.daemon.jvmargs=-Xmx4g --stacktrace
./gradlew assembleRelease --stacktrace

- name: Build EXP Mango Lite debug
- name: Build EXP Mango Debug
if: steps.signing.outputs.available != 'true'
run: |
cd wmkeyboard
./gradlew assembleLiteEnDebug -Pwmkb.noInternet=true -Pkotlin.daemon.jvmargs=-Xmx4g --stacktrace
./gradlew assembleDebug --stacktrace

- name: Verify APK signature
if: steps.signing.outputs.available == 'true'
run: |
APK="wmkeyboard/app/build/outputs/apk/liteEn/release/app-liteEn-release.apk"
APK="app/build/outputs/apk/release/app-release.apk"
test -f "$APK"
APKSIGNER="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)"
"$APKSIGNER" verify --verbose --print-certs "$APK"
Expand All @@ -89,17 +88,18 @@ jobs:
- name: Collect APK
run: |
if [ "${{ steps.signing.outputs.available }}" = "true" ]; then
cp wmkeyboard/app/build/outputs/apk/liteEn/release/app-liteEn-release.apk EXP-Mango.apk
cp app/build/outputs/apk/release/app-release.apk EXP-Mango.apk
else
APK="$(find wmkeyboard/app/build/outputs/apk -type f -path '*/debug/*.apk' -print -quit)"
APK="$(find app/build/outputs/apk -type f -name '*.apk' -print -quit)"
test -n "$APK"
cp "$APK" EXP-Mango-debug.apk
cp "$APK" EXP-Mango.apk
fi

- name: Upload APK
- name: Upload APK Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.signing.outputs.available == 'true' && 'EXP-Mango' || 'EXP-Mango-current-test' }}
name: EXP-Mango-test
path: |
EXP-Mango.apk
EXP-Mango-debug.apk
Expand Down
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
build/
app/build/
.gradle/
.kotlin/
.idea/
*.iml
*.apk
*.aar
*.dex
*.class
local.properties
*.keystore
*.jks
release.keystore
.navigation/
captures/
*.kwks
17 changes: 16 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionName = "1.0.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
Expand Down Expand Up @@ -40,4 +42,17 @@ android {
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}

testOptions {
unitTests.isIncludeAndroidResources = true
}
}

dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")

testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20231013")
}
125 changes: 125 additions & 0 deletions app/src/main/java/com/mangoloads/expmango/DictionaryManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.mangoloads.expmango

import android.content.Context
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap

enum class WordCategory(val displayName: String) {
ENGLISH("English"),
HINGLISH("Hinglish"),
SLANG("Slang"),
INDIAN_ENGLISH("Indian English"),
UPSC("UPSC"),
TECHNOLOGY("Technology"),
NAMES("Names"),
PERSONAL("Personal"),
OTHER("Other")
}

data class CustomWord(
val word: String,
val category: WordCategory = WordCategory.PERSONAL,
val frequency: Int = 100
)

class DictionaryManager(context: Context) {

private val appContext = context.applicationContext
private val filesDir: File = try {
appContext.filesDir ?: File("build/test-tmp").apply { mkdirs() }
} catch (_: Exception) {
File("build/test-tmp").apply { mkdirs() }
}

private val customWordsFile = File(filesDir, "custom_dictionary.tsv")
private val words = ConcurrentHashMap<String, CustomWord>()

init {
loadCustomWords()
}

fun addWord(word: String, category: WordCategory = WordCategory.PERSONAL, frequency: Int = 100) {
val clean = word.trim().lowercase(Locale.ROOT)
if (clean.isNotBlank() && clean.none { it.isWhitespace() }) {
words[clean] = CustomWord(clean, category, frequency)
saveCustomWords()
}
}

fun addBulkWords(lines: List<String>, category: WordCategory = WordCategory.PERSONAL) {
lines.forEach { line ->
val clean = line.trim().lowercase(Locale.ROOT)
if (clean.isNotBlank() && !clean.startsWith("#") && clean.none { it.isWhitespace() }) {
words[clean] = CustomWord(clean, category, 100)
}
}
saveCustomWords()
}

fun removeWord(word: String) {
val clean = word.trim().lowercase(Locale.ROOT)
if (words.remove(clean) != null) {
saveCustomWords()
}
}

fun getWords(category: WordCategory? = null): List<CustomWord> {
val list = words.values.toList()
return if (category == null) list else list.filter { it.category == category }
}

fun search(query: String): List<CustomWord> {
val q = query.trim().lowercase(Locale.ROOT)
if (q.isBlank()) return getWords()
return words.values.filter { it.word.contains(q) }
}

fun reset() {
words.clear()
if (customWordsFile.exists()) {
customWordsFile.delete()
}
}

fun getWordCount(): Int = words.size

fun getCategoryCounts(): Map<WordCategory, Int> {
val counts = WordCategory.values().associateWith { 0 }.toMutableMap()
words.values.forEach { cw ->
counts[cw.category] = (counts[cw.category] ?: 0) + 1
}
return counts
}

private fun loadCustomWords() {
if (!customWordsFile.exists()) return
try {
customWordsFile.forEachLine { line ->
val parts = line.split('\t')
if (parts.isNotEmpty()) {
val w = parts[0].trim().lowercase(Locale.ROOT)
val cat = parts.getOrNull(1)?.let {
try { WordCategory.valueOf(it) } catch (_: Exception) { WordCategory.PERSONAL }
} ?: WordCategory.PERSONAL
val freq = parts.getOrNull(2)?.toIntOrNull() ?: 100
if (w.isNotBlank()) {
words[w] = CustomWord(w, cat, freq)
}
}
}
} catch (_: Exception) {
}
}

private fun saveCustomWords() {
try {
customWordsFile.printWriter().use { out ->
words.values.sortedBy { it.word }.forEach { cw ->
out.println("${cw.word}\t${cw.category.name}\t${cw.frequency}")
}
}
} catch (_: Exception) {
}
}
}
52 changes: 52 additions & 0 deletions app/src/main/java/com/mangoloads/expmango/EmailDomainProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.mangoloads.expmango

import java.util.Locale
import java.util.concurrent.ConcurrentHashMap

class EmailDomainProvider {

private val builtInDomains = listOf(
"gmail.com",
"outlook.com",
"yahoo.com",
"icloud.com",
"proton.me",
"protonmail.com",
"hotmail.com"
)

private val learnedDomains = ConcurrentHashMap<String, Int>()

init {
// Initialize default weights for built-in domains
builtInDomains.forEachIndexed { idx, domain ->
learnedDomains[domain] = 100 - idx * 5
}
}

fun learnDomain(domain: String) {
val clean = domain.trim().lowercase(Locale.ROOT).removePrefix("@")
if (clean.length > 3 && clean.contains(".")) {
learnedDomains.compute(clean) { _, count -> (count ?: 10) + 20 }
}
}

fun getDomainSuggestions(token: String, limit: Int = 3): List<String> {
val atIndex = token.lastIndexOf('@')
if (atIndex < 0) return emptyList()

val prefix = token.substring(atIndex + 1).lowercase(Locale.ROOT)

return learnedDomains.entries
.asSequence()
.filter { (domain, _) -> domain.startsWith(prefix) }
.sortedByDescending { it.value }
.map { it.key }
.take(limit)
.toList()
}

fun allDomains(): List<String> = learnedDomains.entries
.sortedByDescending { it.value }
.map { it.key }
}
Loading
Loading