From b66e7a77b15159123124809eba3f0d29ed17fe27 Mon Sep 17 00:00:00 2001 From: jbriones95 <108902016+jbriones95@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:03:53 -0600 Subject: [PATCH 1/5] Improve multi-feed ingestion and directionless route support - Merge Bustang GTFS into RTD Denver as an additional static feed, with per-feed ID namespacing (feed1:) to prevent key collisions. - Rework GtfsIngestor to build a temp database and atomically move it into place, avoiding deleting an in-use database (SQLiteReadOnlyDatabaseException). - Use ZipFile instead of ZipInputStream for reliable entry reads. - Serialize ingestion with a mutex and cancel the previous ingest job when switching agencies. - Store feed URLs in feed_meta.txt so a changed URL forces a refresh. - Support directionless routes (nullable directionId) end-to-end and auto-skip direction selection when a route has no directions. - Disable RIPTA realtime (HTTP-only feeds) and remove the :netconfig cleartext exception module. --- README.md | 2 +- netconfig/build.gradle.kts | 40 ------ netconfig/src/main/AndroidManifest.xml | 9 -- .../main/res/xml/network_security_config.xml | 11 -- settings.gradle.kts | 3 +- tool/README.md | 2 +- tool/build.gradle.kts | 4 - .../transit/DepartureListScreen.kt | 4 +- .../transit/DirectionSelectionScreen.kt | 9 ++ .../transit/FirstStopSelectionScreen.kt | 4 +- .../com/thelightphone/transit/HomeScreen.kt | 19 ++- .../thelightphone/transit/gtfs/GtfsAgency.kt | 20 +-- .../transit/gtfs/GtfsIngestor.kt | 127 +++++++++++------- .../transit/gtfs/GtfsRepository.kt | 25 ++-- 14 files changed, 138 insertions(+), 141 deletions(-) delete mode 100644 netconfig/build.gradle.kts delete mode 100644 netconfig/src/main/AndroidManifest.xml delete mode 100644 netconfig/src/main/res/xml/network_security_config.xml diff --git a/README.md b/README.md index 90ba42b0..417cc391 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ That's it โ€” happy transit-ing! ๐Ÿš๐ŸšŒ๐Ÿš† ## ๐Ÿงช A couple of nerdy notes -- **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception โ€” see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. +- **RIPTA's live feeds are HTTP-only** (no HTTPS), so realtime tracking is disabled for RIPTA while static schedule data remains available. - **No device GPS is used anywhere** โ€” the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! ๐Ÿ™ - **Stations are deduplicated using GTFS's `parent_station`** โ€” a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. Only real platforms and boarding areas count as "member platforms" for this โ€” GTFS also links entrances, elevators, and escalator nodes to the same parent station, and those are filtered out so a big hub's map isn't cluttered with dozens of non-boardable points. - **Boarding a trip is a saved reference, not a background tracker** โ€” Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. diff --git a/netconfig/build.gradle.kts b/netconfig/build.gradle.kts deleted file mode 100644 index bc134fee..00000000 --- a/netconfig/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -// --- BEGIN removable cleartext exception for RIPTA realtime feeds --- -// -// This module exists solely to grant realtime.ripta.com a Network Security Config cleartext -// exception (see src/main/res/xml/network_security_config.xml). RIPTA's realtime TripUpdates/ -// VehiclePositions feeds are served plain-HTTP-only with no HTTPS equivalent, and Android blocks -// cleartext traffic by default โ€” this module's manifest merges the exception into :tool's final -// packaged manifest. -// -// Deliberately does NOT apply the com.thelightphone.light-sdk plugin โ€” that plugin's manifest -// generation has no field for network security config, and hand-editing an -// AndroidManifest.xml in a plugin-applying module is rejected outright. A plain sibling library -// module sidesteps that: the plugin's own dependency validator explicitly exempts same-build -// project dependencies (see LightSdkPlugin.isProjectDependency), and since this module never -// applies the plugin, none of its restrictions apply to it either. Verified against a real forced -// rebuild that the merged attribute survives into :tool's final packaged manifest โ€” confirmed via -// tool/build/intermediates/packaged_manifests/.../AndroidManifest.xml, not just the intermediate -// merge blame log. -// -// TO REMOVE THIS EXCEPTION (restore HTTPS-only enforcement everywhere): -// 1. Delete this module (the netconfig/ directory). -// 2. Remove `include(":netconfig")` from settings.gradle.kts. -// 3. Remove `implementation(project(":netconfig"))` from tool/build.gradle.kts. -// 4. In GtfsAgency.kt, set RIPTA's realtimeTripUpdatesUrl/realtimeVehiclePositionsUrl back to -// null (the original, HTTPS-only-safe state). -// -// UNVERIFIED: whether Light's official build/signing pipeline (builder/) accepts a sibling module -// built this way โ€” only confirmed against local Gradle builds so far. -plugins { - alias(libs.plugins.android.library) -} - -android { - namespace = "com.thelightphone.netconfig" - compileSdk = rootProject.ext["compileSdk"] as Int - - defaultConfig { - minSdk = rootProject.ext["minSdk"] as Int - } -} -// --- END removable cleartext exception for RIPTA realtime feeds --- diff --git a/netconfig/src/main/AndroidManifest.xml b/netconfig/src/main/AndroidManifest.xml deleted file mode 100644 index 39835cf5..00000000 --- a/netconfig/src/main/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - diff --git a/netconfig/src/main/res/xml/network_security_config.xml b/netconfig/src/main/res/xml/network_security_config.xml deleted file mode 100644 index c6774ceb..00000000 --- a/netconfig/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - realtime.ripta.com - - diff --git a/settings.gradle.kts b/settings.gradle.kts index 694db05e..3ccdbd9e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ val ghPassword = localProperties.getProperty("gpr.key") ?: System.getenv("GH_PAC dependencyResolutionManagement { repositories { + mavenLocal() google() mavenCentral() maven { @@ -41,8 +42,6 @@ include(":sdk:client") include(":sdk:server") include(":sdk:emulator") include(":tool") -// REMOVABLE: see netconfig/build.gradle.kts for what this is and how to fully remove it. -include(":netconfig") include(":examples:ui-demo") project(":examples:ui-demo").projectDir = file("examples/ui-demo") include(":examples:weather") diff --git a/tool/README.md b/tool/README.md index 3bcdf209..bb6cdd95 100644 --- a/tool/README.md +++ b/tool/README.md @@ -59,7 +59,7 @@ That's it โ€” happy transit-ing! ๐Ÿš๐ŸšŒ๐Ÿš† ## ๐Ÿงช A couple of nerdy notes -- **RIPTA's live feeds are HTTP-only** (no HTTPS), which Android blocks by default. There's a small, clearly-labeled `:netconfig` module that grants just that one narrow exception โ€” see its own `build.gradle.kts` for exactly what it does and how to remove it if you'd rather stay HTTPS-only everywhere. +- **RIPTA's live feeds are HTTP-only** (no HTTPS), so realtime tracking is disabled for RIPTA while static schedule data remains available. - **No device GPS is used anywhere** โ€” the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! ๐Ÿ™ - **Stations are deduplicated using GTFS's `parent_station`** โ€” a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. Only real platforms and boarding areas count as "member platforms" for this โ€” GTFS also links entrances, elevators, and escalator nodes to the same parent station, and those are filtered out so a big hub's map isn't cluttered with dozens of non-boardable points. - **Boarding a trip is a saved reference, not a background tracker** โ€” Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. diff --git a/tool/build.gradle.kts b/tool/build.gradle.kts index 15f3a7cf..6ad688a8 100644 --- a/tool/build.gradle.kts +++ b/tool/build.gradle.kts @@ -59,10 +59,6 @@ kotlin { dependencies { implementation(project(":sdk:client")) - // REMOVABLE: grants realtime.ripta.com a cleartext exception so its plain-HTTP-only realtime - // feeds are reachable. See netconfig/build.gradle.kts for the full explanation and removal - // steps (remove this line, the settings.gradle.kts include, and the module itself). - implementation(project(":netconfig")) // Only the "org.jetbrains.kotlinx:kotlinx-serialization" prefix is on the SDK plugin's // dependency allow-list, but that check is a startsWith match, so this artifact passes too โ€” // verified against a live build. No official protobuf/gtfs-realtime-bindings library is diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt index 92c0d63a..7150e14c 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DepartureListScreen.kt @@ -55,7 +55,7 @@ sealed class DepartureListState { class DepartureListViewModel( dbFile: File, private val routeId: String, - private val directionId: Int, + private val directionId: Int?, private val stopId: String, ) : LightViewModel() { @@ -88,7 +88,7 @@ class DepartureListScreen( private val dbFile: File, private val routeId: String, private val routeLabel: String, - private val directionId: Int, + private val directionId: Int?, private val directionLabel: String, private val stopId: String, private val stopLabel: String, diff --git a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt index 5361bdb3..1d659f57 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/DirectionSelectionScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -102,6 +103,14 @@ class DirectionSelectionScreen( val state by viewModel.state.collectAsState() val themeColors by LightThemeController.colors.collectAsState() + LaunchedEffect(state) { + if (state is DirectionSelectionState.Loaded && (state as DirectionSelectionState.Loaded).directions.isEmpty()) { + navigateTo(screenFactory = { activity -> + FirstStopSelectionScreen(activity, dbFile, routeId, routeLabel, null, "Route") + }) + } + } + LightTheme(colors = themeColors) { Column( modifier = Modifier diff --git a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt index 2e5ba319..70439c93 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/FirstStopSelectionScreen.kt @@ -51,7 +51,7 @@ fun StopOption.displayLabel(): String = stopName?.takeIf { it.isNotBlank() } ?: class FirstStopSelectionViewModel( dbFile: File, private val routeId: String, - private val directionId: Int, + private val directionId: Int?, ) : LightViewModel() { private val repository = GtfsRepository(dbFile) @@ -85,7 +85,7 @@ class FirstStopSelectionScreen( private val dbFile: File, private val routeId: String, private val routeLabel: String, - private val directionId: Int, + private val directionId: Int?, private val directionLabel: String, ) : LightScreen(sealedActivity) { diff --git a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt index 33d85eac..c8d31324 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/HomeScreen.kt @@ -61,6 +61,7 @@ import com.thelightphone.sdk.ui.LightThemeTokens import com.thelightphone.sdk.ui.gridUnitsAsDp import com.thelightphone.sdk.ui.lightClickable import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -165,7 +166,6 @@ private fun dailyMessage(): String { * stopping at the homescreen, this both prevents infinite screens from opening and assures the home screen * can be easily returned to. */ - object HomeVisibility { val isVisible = MutableStateFlow(false) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -303,6 +303,7 @@ class HomeScreenViewModel( /** The one agency (if any) currently checking for updates/downloading/parsing right now -- * shows a spinning sync icon next to that agency's name only. */ val syncingAgency = MutableStateFlow(null) + private var agencyIngestJob: Job? = null /** Whether [readyAgency] has any real, qualifying multi-platform stations at all (see * GtfsRepository.getAllStations) -- an agency with none (e.g. RIPTA, which has no grouped @@ -423,6 +424,7 @@ class HomeScreenViewModel( } fun selectAgency(agency: GtfsAgency) { + agencyIngestJob?.cancel() selectedAgency.value = agency readyAgency.value = null status.value = null @@ -430,14 +432,17 @@ class HomeScreenViewModel( agencyHasStations.value = false Log.d("HomeScreen", "Selected agency: ${agency.displayName}") - viewModelScope.launch(Dispatchers.IO) { + agencyIngestJob = viewModelScope.launch(Dispatchers.IO) { try { ingestor.ingest(agency) { ingestStatus -> - if (ingestStatus == GtfsIngestStatus.Ready) { + if (ingestStatus == GtfsIngestStatus.Ready && selectedAgency.value == agency) { syncingAgency.value = null cachedAgencies.value = cachedAgencies.value + agency } } + // A later selection may have started another ingest while this one was running. + // Do not let the older job replace the newer agency's ready state or station check. + if (selectedAgency.value != agency) return@launch readyAgency.value = agency val stationRepo = GtfsRepository(gtfsDbFile(filesDir, agency)) try { @@ -445,10 +450,14 @@ class HomeScreenViewModel( } finally { stationRepo.close() } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("HomeScreen", "GTFS ingestion failed for ${agency.displayName}", e) - syncingAgency.value = null - status.value = "Unable to load ${agency.displayName} data." + if (selectedAgency.value == agency) { + syncingAgency.value = null + status.value = "Unable to load ${agency.displayName} data." + } } } } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt index 797e3beb..c2263f79 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -7,11 +7,8 @@ import java.io.File * feed reachable at all. Screens treat "null or fetch failed" identically, so adding/removing a * URL here is the only change a screen-level caller ever needs to make. * - * RIPTA's realtime service (realtime.ripta.com) is plain-HTTP-only with no HTTPS equivalent for - * either feed, which Android blocks by default. Its URLs below are only reachable because of a - * REMOVABLE cleartext exception โ€” see the :netconfig module (netconfig/build.gradle.kts) for the - * full explanation and exact removal steps. To restore HTTPS-only enforcement everywhere, remove - * that module (per its own instructions) AND set RIPTA's two URLs below back to null. + * RIPTA's realtime service is plain-HTTP-only with no HTTPS equivalent, so it is intentionally + * disabled here. Static GTFS remains available until an HTTPS realtime endpoint is published. */ enum class GtfsAgency( val id: String, @@ -19,9 +16,11 @@ enum class GtfsAgency( val feedUrl: String, val realtimeTripUpdatesUrl: String?, val realtimeVehiclePositionsUrl: String?, - /** Optional extra data sources beyond the four feed URLs above -- see [AgencyComponent]. Empty + /** Optional extra data sources beyond the feed URLs above -- see [AgencyComponent]. Empty * for any agency that doesn't have one (e.g. RIPTA, today). */ val components: List = emptyList(), + /** Additional static feeds merged into this agency's database, with IDs namespaced per feed. */ + val additionalStaticFeedUrls: List = emptyList(), ) { MBTA( "mbta", @@ -37,15 +36,16 @@ enum class GtfsAgency( "https://www.rtd-denver.com/files/gtfs/google_transit.zip", "https://open-data.rtd-denver.com/files/gtfs-rt/rtd/TripUpdate.pb", "https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb", + additionalStaticFeedUrls = listOf( + "https://www.rtd-denver.com/files/gtfs/bustang-co-us.zip", + ), ), - // REMOVABLE: these two URLs only work because of the :netconfig cleartext exception (see - // class doc above). Set both back to null to restore HTTPS-only enforcement for RIPTA. RIPTA( "ripta", "RIPTA", "https://ripta.com/RIPTA-GTFS.zip", - "http://realtime.ripta.com:81/api/tripupdates?format=gtfs.proto", - "http://realtime.ripta.com:81/api/vehiclepositions?format=gtfs.proto", + null, + null, ), ; diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt index 483cfd0a..5c3804ce 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -11,7 +11,11 @@ import io.ktor.http.HttpHeaders import java.io.BufferedReader import java.io.File import java.net.URI -import java.util.zip.ZipInputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.zip.ZipFile +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock enum class GtfsIngestStatus { CheckingForUpdates, Downloading, Parsing, Ready @@ -33,6 +37,7 @@ private data class FeedMeta(val etag: String, val lastModified: String) { /** Downloads, unzips, and bulk-loads an agency's GTFS static feed into a local SQLite database. */ class GtfsIngestor(private val filesDir: File) { + private val ingestMutex = Mutex() /** * Re-downloads only when the feed has actually changed: a HEAD request's ETag/Last-Modified is @@ -41,42 +46,61 @@ class GtfsIngestor(private val filesDir: File) { * the HEAD request. Changed, or nothing cached yet, or the check itself is inconclusive -> falls * back to a full re-download, since that's always safe (just not always necessary). */ - suspend fun ingest(agency: GtfsAgency, onStatus: (GtfsIngestStatus) -> Unit) { + suspend fun ingest(agency: GtfsAgency, onStatus: (GtfsIngestStatus) -> Unit) = ingestMutex.withLock { + ingestInternal(agency, onStatus) + } + + private suspend fun ingestInternal(agency: GtfsAgency, onStatus: (GtfsIngestStatus) -> Unit) { val agencyDir = File(filesDir, "gtfs/${agency.id}") agencyDir.mkdirs() - val zipFile = File(agencyDir, "gtfs.zip") + val feedUrls = listOf(agency.feedUrl) + agency.additionalStaticFeedUrls + val zipFiles = feedUrls.mapIndexed { index, _ -> + File(agencyDir, if (index == 0) "gtfs.zip" else "gtfs-$index.zip") + } val dbFile = gtfsDbFile(filesDir, agency) val metaFile = File(agencyDir, "feed_meta.txt") onStatus(GtfsIngestStatus.CheckingForUpdates) - val cachedMeta = readFeedMeta(metaFile) + val cachedMeta = readFeedMeta(metaFile, feedUrls) val remoteMeta = try { - checkForUpdate(agency.feedUrl) + feedUrls.map { checkForUpdate(it) }.takeIf { metas -> metas.all { it != null } }?.map { it!! } } catch (e: Exception) { Log.e("GtfsIngestor", "Feed update check failed for ${agency.displayName}, redownloading to be safe", e) null } val upToDate = dbFile.exists() && cachedMeta != null && remoteMeta != null && - !cachedMeta.isEmpty() && cachedMeta == remoteMeta + cachedMeta.size == remoteMeta.size && cachedMeta.zip(remoteMeta).all { (cached, remote) -> + !cached.isEmpty() && cached == remote + } if (upToDate) { onStatus(GtfsIngestStatus.Ready) return } onStatus(GtfsIngestStatus.Downloading) - downloadZip(agency.feedUrl, zipFile) + feedUrls.zip(zipFiles).forEach { (url, zipFile) -> downloadZip(url, zipFile) } onStatus(GtfsIngestStatus.Parsing) - dbFile.delete() - val db = openGtfsDatabase(dbFile) + val tempDbFile = File(agencyDir, "transit.db.tmp") + tempDbFile.delete() + val db = openGtfsDatabase(tempDbFile) try { - parseAndLoad(zipFile, db) + clearGtfsTables(db) + zipFiles.forEachIndexed { index, zipFile -> + parseAndLoad(zipFile, db, if (index == 0) "" else "feed$index:") + } } finally { db.close() } + Files.move( + tempDbFile.toPath(), + dbFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) - remoteMeta?.let { writeFeedMeta(metaFile, it) } + remoteMeta?.let { metas -> writeFeedMeta(metaFile, feedUrls, metas) } onStatus(GtfsIngestStatus.Ready) } @@ -112,15 +136,21 @@ class GtfsIngestor(private val filesDir: File) { } } - private fun readFeedMeta(file: File): FeedMeta? { + private fun readFeedMeta(file: File, feedUrls: List): List? { if (!file.exists()) return null val lines = file.readLines() - if (lines.size < 2) return null - return FeedMeta(etag = lines[0], lastModified = lines[1]) + if (lines.size < feedUrls.size * 3) return null + return feedUrls.mapIndexed { index, url -> + val offset = index * 3 + if (lines[offset] != url) return null + FeedMeta(etag = lines[offset + 1], lastModified = lines[offset + 2]) + } } - private fun writeFeedMeta(file: File, meta: FeedMeta) { - file.writeText("${meta.etag}\n${meta.lastModified}\n") + private fun writeFeedMeta(file: File, feedUrls: List, metas: List) { + file.writeText(feedUrls.zip(metas).joinToString("\n") { (url, meta) -> + "$url\n${meta.etag}\n${meta.lastModified}" + } + "\n") } /** @@ -159,19 +189,19 @@ class GtfsIngestor(private val filesDir: File) { } } - private fun parseAndLoad(zipFile: File, db: SQLiteDatabase) { + private fun parseAndLoad(zipFile: File, db: SQLiteDatabase, idPrefix: String) { db.beginTransaction() try { - ZipInputStream(zipFile.inputStream()).use { zis -> - var entry = zis.nextEntry - while (entry != null) { + ZipFile(zipFile).use { archive -> + val entries = archive.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() val loader = TABLE_LOADERS[entry.name.substringAfterLast('/')] if (loader != null) { - val reader = BufferedReader(zis.reader(Charsets.UTF_8)) - loader(db, reader) + archive.getInputStream(entry).reader(Charsets.UTF_8).buffered().use { reader -> + loader(db, reader, idPrefix) + } } - zis.closeEntry() - entry = zis.nextEntry } } db.setTransactionSuccessful() @@ -181,7 +211,7 @@ class GtfsIngestor(private val filesDir: File) { } companion object { - private val TABLE_LOADERS: Map Unit> = mapOf( + private val TABLE_LOADERS: Map Unit> = mapOf( "routes.txt" to ::loadRoutes, "trips.txt" to ::loadTrips, "stops.txt" to ::loadStops, @@ -202,8 +232,18 @@ private fun secureRedirectUrl(currentUrl: String, location: String): String { } } -private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader) { +private fun clearGtfsTables(db: SQLiteDatabase) { + db.delete("stop_times", null, null) + db.delete("trips", null, null) db.delete("routes", null, null) + db.delete("stops", null, null) + db.delete("calendar_dates", null, null) + db.delete("calendar", null, null) +} + +private fun prefixedId(prefix: String, id: String?): String? = id?.takeIf { it.isNotEmpty() }?.let { prefix + it } + +private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO routes @@ -212,7 +252,7 @@ private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val routeId = header.get(row, "route_id") ?: return@readCsvEntry + val routeId = prefixedId(idPrefix, header.get(row, "route_id")) ?: return@readCsvEntry stmt.clearBindings() stmt.bindString(1, routeId) stmt.bindStringOrNull(2, header.get(row, "agency_id")) @@ -227,8 +267,7 @@ private fun loadRoutes(db: SQLiteDatabase, reader: BufferedReader) { } } -private fun loadTrips(db: SQLiteDatabase, reader: BufferedReader) { - db.delete("trips", null, null) +private fun loadTrips(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO trips @@ -237,9 +276,9 @@ private fun loadTrips(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val tripId = header.get(row, "trip_id") ?: return@readCsvEntry - val routeId = header.get(row, "route_id") ?: return@readCsvEntry - val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + val tripId = prefixedId(idPrefix, header.get(row, "trip_id")) ?: return@readCsvEntry + val routeId = prefixedId(idPrefix, header.get(row, "route_id")) ?: return@readCsvEntry + val serviceId = prefixedId(idPrefix, header.get(row, "service_id")) ?: return@readCsvEntry stmt.clearBindings() stmt.bindString(1, tripId) stmt.bindString(2, routeId) @@ -255,8 +294,7 @@ private fun loadTrips(db: SQLiteDatabase, reader: BufferedReader) { } } -private fun loadStops(db: SQLiteDatabase, reader: BufferedReader) { - db.delete("stops", null, null) +private fun loadStops(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO stops @@ -265,7 +303,7 @@ private fun loadStops(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val stopId = header.get(row, "stop_id") ?: return@readCsvEntry + val stopId = prefixedId(idPrefix, header.get(row, "stop_id")) ?: return@readCsvEntry stmt.clearBindings() stmt.bindString(1, stopId) stmt.bindStringOrNull(2, header.get(row, "stop_code")) @@ -276,14 +314,13 @@ private fun loadStops(db: SQLiteDatabase, reader: BufferedReader) { stmt.bindStringOrNull(7, header.get(row, "zone_id")) stmt.bindStringOrNull(8, header.get(row, "stop_url")) stmt.bindLongOrNull(9, header.get(row, "location_type")) - stmt.bindStringOrNull(10, header.get(row, "parent_station")) + stmt.bindStringOrNull(10, prefixedId(idPrefix, header.get(row, "parent_station"))) stmt.bindLongOrNull(11, header.get(row, "wheelchair_boarding")) stmt.executeInsert() } } -private fun loadStopTimes(db: SQLiteDatabase, reader: BufferedReader) { - db.delete("stop_times", null, null) +private fun loadStopTimes(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO stop_times @@ -292,9 +329,9 @@ private fun loadStopTimes(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val tripId = header.get(row, "trip_id") ?: return@readCsvEntry + val tripId = prefixedId(idPrefix, header.get(row, "trip_id")) ?: return@readCsvEntry val stopSequence = header.get(row, "stop_sequence")?.toLongOrNull() ?: return@readCsvEntry - val stopId = header.get(row, "stop_id") ?: return@readCsvEntry + val stopId = prefixedId(idPrefix, header.get(row, "stop_id")) ?: return@readCsvEntry stmt.clearBindings() stmt.bindString(1, tripId) stmt.bindLong(2, stopSequence) @@ -309,8 +346,7 @@ private fun loadStopTimes(db: SQLiteDatabase, reader: BufferedReader) { } } -private fun loadCalendar(db: SQLiteDatabase, reader: BufferedReader) { - db.delete("calendar", null, null) +private fun loadCalendar(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO calendar @@ -319,7 +355,7 @@ private fun loadCalendar(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + val serviceId = prefixedId(idPrefix, header.get(row, "service_id")) ?: return@readCsvEntry stmt.clearBindings() stmt.bindString(1, serviceId) stmt.bindLongOrNull(2, header.get(row, "monday")) @@ -335,8 +371,7 @@ private fun loadCalendar(db: SQLiteDatabase, reader: BufferedReader) { } } -private fun loadCalendarDates(db: SQLiteDatabase, reader: BufferedReader) { - db.delete("calendar_dates", null, null) +private fun loadCalendarDates(db: SQLiteDatabase, reader: BufferedReader, idPrefix: String) { val stmt = db.compileStatement( """ INSERT INTO calendar_dates (service_id, date, exception_type) @@ -344,7 +379,7 @@ private fun loadCalendarDates(db: SQLiteDatabase, reader: BufferedReader) { """ ) readCsvEntry(reader) { header, row -> - val serviceId = header.get(row, "service_id") ?: return@readCsvEntry + val serviceId = prefixedId(idPrefix, header.get(row, "service_id")) ?: return@readCsvEntry val date = header.get(row, "date") ?: return@readCsvEntry val exceptionType = header.get(row, "exception_type")?.toLongOrNull() ?: return@readCsvEntry stmt.clearBindings() diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt index ac2f673c..a3ffd7e8 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsRepository.kt @@ -36,7 +36,7 @@ enum class LineType(val gtfsRouteTypes: Set, val label: String, val emoji: } } -data class DirectionOption(val directionId: Int, val headsign: String?) +data class DirectionOption(val directionId: Int?, val headsign: String?) data class StopOption( val stopId: String, @@ -184,18 +184,20 @@ class GtfsRepository(dbFile: File) { * earliest stop_sequence across those trips โ€” an approximation of physical route order, * since GTFS doesn't guarantee stop_sequence numbering is identical across trip variants. */ - fun getStops(routeId: String, directionId: Int): List = - db.rawQuery( + fun getStops(routeId: String, directionId: Int?): List { + val directionClause = if (directionId == null) "t.direction_id IS NULL" else "t.direction_id = ?" + val args = if (directionId == null) arrayOf(routeId) else arrayOf(routeId, directionId.toString()) + return db.rawQuery( """ SELECT st.stop_id, s.stop_name, s.stop_lat, s.stop_lon FROM trips t JOIN stop_times st ON st.trip_id = t.trip_id JOIN stops s ON s.stop_id = st.stop_id - WHERE t.route_id = ? AND t.direction_id = ? + WHERE t.route_id = ? AND $directionClause GROUP BY st.stop_id, s.stop_name, s.stop_lat, s.stop_lon ORDER BY MIN(st.stop_sequence) """, - arrayOf(routeId, directionId.toString()), + args, ).use { cursor -> cursor.mapRows { StopOption( @@ -206,6 +208,7 @@ class GtfsRepository(dbFile: File) { ) } } + } /** * Departures for [stopId] on [routeId]+[directionId], restricted to trips whose service_id @@ -217,22 +220,28 @@ class GtfsRepository(dbFile: File) { * restricted to route termini; each result carries the matched stop_sequence so trip detail * can filter to "from this stop onward" instead of assuming the trip starts there. */ - fun getDepartures(routeId: String, directionId: Int, stopId: String, today: LocalDate): List { + fun getDepartures(routeId: String, directionId: Int?, stopId: String, today: LocalDate): List { val todayGtfs = today.toGtfsDateString() val dayColumn = today.dayOfWeek.toGtfsColumnName() + val directionClause = if (directionId == null) "t.direction_id IS NULL" else "t.direction_id = ?" val sql = """ SELECT st.departure_time, t.trip_id, t.trip_headsign, st.stop_sequence FROM trips t JOIN stop_times st ON st.trip_id = t.trip_id - WHERE t.route_id = ? AND t.direction_id = ? AND st.stop_id = ? + WHERE t.route_id = ? AND $directionClause AND st.stop_id = ? AND ${activeTodayClause(dayColumn)} ORDER BY st.departure_time """.trimIndent() + val args = if (directionId == null) { + arrayOf(routeId, stopId, todayGtfs, todayGtfs, todayGtfs) + } else { + arrayOf(routeId, directionId.toString(), stopId, todayGtfs, todayGtfs, todayGtfs) + } return db.rawQuery( sql, - arrayOf(routeId, directionId.toString(), stopId, todayGtfs, todayGtfs, todayGtfs), + args, ).use { cursor -> cursor.mapRows { Departure( From a5296cbe31177690ffe870b9cce61b19ef647ed9 Mon Sep 17 00:00:00 2001 From: jbriones95 <108902016+jbriones95@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:44:20 -0600 Subject: [PATCH 2/5] Restore RIPTA live realtime feeds --- README.md | 2 +- netconfig/build.gradle.kts | 19 +++++++++++++++++++ netconfig/src/main/AndroidManifest.xml | 5 +++++ .../main/res/xml/network_security_config.xml | 7 +++++++ settings.gradle.kts | 1 + tool/README.md | 2 +- tool/build.gradle.kts | 2 ++ .../thelightphone/transit/gtfs/GtfsAgency.kt | 8 ++++---- 8 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 netconfig/build.gradle.kts create mode 100644 netconfig/src/main/AndroidManifest.xml create mode 100644 netconfig/src/main/res/xml/network_security_config.xml diff --git a/README.md b/README.md index 417cc391..ea3e47f9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ That's it โ€” happy transit-ing! ๐Ÿš๐ŸšŒ๐Ÿš† ## ๐Ÿงช A couple of nerdy notes -- **RIPTA's live feeds are HTTP-only** (no HTTPS), so realtime tracking is disabled for RIPTA while static schedule data remains available. +- **RIPTA's live feeds are HTTP-only** (no HTTPS). A narrowly scoped `:netconfig` exception permits realtime requests only to `realtime.ripta.com`. - **No device GPS is used anywhere** โ€” the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! ๐Ÿ™ - **Stations are deduplicated using GTFS's `parent_station`** โ€” a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. Only real platforms and boarding areas count as "member platforms" for this โ€” GTFS also links entrances, elevators, and escalator nodes to the same parent station, and those are filtered out so a big hub's map isn't cluttered with dozens of non-boardable points. - **Boarding a trip is a saved reference, not a background tracker** โ€” Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. diff --git a/netconfig/build.gradle.kts b/netconfig/build.gradle.kts new file mode 100644 index 00000000..f0078fe8 --- /dev/null +++ b/netconfig/build.gradle.kts @@ -0,0 +1,19 @@ +// --- BEGIN removable cleartext exception for RIPTA realtime feeds --- +// +// This module exists solely to grant realtime.ripta.com a Network Security Config cleartext +// exception (see src/main/res/xml/network_security_config.xml). RIPTA's realtime TripUpdates/ +// VehiclePositions feeds are served plain-HTTP-only with no HTTPS equivalent, and Android blocks +// cleartext traffic by default. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.thelightphone.netconfig" + compileSdk = rootProject.ext["compileSdk"] as Int + + defaultConfig { + minSdk = rootProject.ext["minSdk"] as Int + } +} +// --- END removable cleartext exception for RIPTA realtime feeds --- diff --git a/netconfig/src/main/AndroidManifest.xml b/netconfig/src/main/AndroidManifest.xml new file mode 100644 index 00000000..669f8ac3 --- /dev/null +++ b/netconfig/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/netconfig/src/main/res/xml/network_security_config.xml b/netconfig/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..da9d4398 --- /dev/null +++ b/netconfig/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + realtime.ripta.com + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 3ccdbd9e..c23a0572 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include(":sdk:client") include(":sdk:server") include(":sdk:emulator") include(":tool") +include(":netconfig") include(":examples:ui-demo") project(":examples:ui-demo").projectDir = file("examples/ui-demo") include(":examples:weather") diff --git a/tool/README.md b/tool/README.md index bb6cdd95..170a71fe 100644 --- a/tool/README.md +++ b/tool/README.md @@ -59,7 +59,7 @@ That's it โ€” happy transit-ing! ๐Ÿš๐ŸšŒ๐Ÿš† ## ๐Ÿงช A couple of nerdy notes -- **RIPTA's live feeds are HTTP-only** (no HTTPS), so realtime tracking is disabled for RIPTA while static schedule data remains available. +- **RIPTA's live feeds are HTTP-only** (no HTTPS). A narrowly scoped `:netconfig` exception permits realtime requests only to `realtime.ripta.com`. - **No device GPS is used anywhere** โ€” the SDK doesn't expose it to tools yet. Nearby-stop and location search are powered by Nominatim (OpenStreetMap) and IP-based geolocation instead. Be kind to their free APIs! ๐Ÿ™ - **Stations are deduplicated using GTFS's `parent_station`** โ€” a big station with several platforms (subway entrances, commuter rail tracks, etc.) shows up as one marker/entry, not one per platform, while still resolving to the right platform's `stop_id` under the hood for schedule lookups. Only real platforms and boarding areas count as "member platforms" for this โ€” GTFS also links entrances, elevators, and escalator nodes to the same parent station, and those are filtered out so a big hub's map isn't cluttered with dozens of non-boardable points. - **Boarding a trip is a saved reference, not a background tracker** โ€” Pico Transit never polls a live feed while the app itself isn't open. "You've reached your stop" detection only runs while Trip Detail or the home screen is actually visible and polling, the same way every other bit of live tracking in the app works. diff --git a/tool/build.gradle.kts b/tool/build.gradle.kts index 6ad688a8..60f84040 100644 --- a/tool/build.gradle.kts +++ b/tool/build.gradle.kts @@ -59,6 +59,8 @@ kotlin { dependencies { implementation(project(":sdk:client")) + // RIPTA's realtime feeds are HTTP-only; netconfig scopes the exception to that host. + implementation(project(":netconfig")) // Only the "org.jetbrains.kotlinx:kotlinx-serialization" prefix is on the SDK plugin's // dependency allow-list, but that check is a startsWith match, so this artifact passes too โ€” // verified against a live build. No official protobuf/gtfs-realtime-bindings library is diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt index c2263f79..ff740b0f 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsAgency.kt @@ -7,8 +7,8 @@ import java.io.File * feed reachable at all. Screens treat "null or fetch failed" identically, so adding/removing a * URL here is the only change a screen-level caller ever needs to make. * - * RIPTA's realtime service is plain-HTTP-only with no HTTPS equivalent, so it is intentionally - * disabled here. Static GTFS remains available until an HTTPS realtime endpoint is published. + * RIPTA's realtime service is plain-HTTP-only with no HTTPS equivalent. Its two URLs below are + * reachable through the narrowly scoped cleartext exception provided by the :netconfig module. */ enum class GtfsAgency( val id: String, @@ -44,8 +44,8 @@ enum class GtfsAgency( "ripta", "RIPTA", "https://ripta.com/RIPTA-GTFS.zip", - null, - null, + "http://realtime.ripta.com:81/api/tripupdates?format=gtfs.proto", + "http://realtime.ripta.com:81/api/vehiclepositions?format=gtfs.proto", ), ; From 5d1db4666928b522b2a3d5f657a808a4674fefac Mon Sep 17 00:00:00 2001 From: jbriones95 <108902016+jbriones95@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:01:07 -0600 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../transit/gtfs/GtfsIngestor.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt index 5c3804ce..1940c94e 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/gtfs/GtfsIngestor.kt @@ -93,12 +93,20 @@ class GtfsIngestor(private val filesDir: File) { } finally { db.close() } - Files.move( - tempDbFile.toPath(), - dbFile.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE, - ) + try { + Files.move( + tempDbFile.toPath(), + dbFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (e: java.nio.file.AtomicMoveNotSupportedException) { + Files.move( + tempDbFile.toPath(), + dbFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } remoteMeta?.let { metas -> writeFeedMeta(metaFile, feedUrls, metas) } onStatus(GtfsIngestStatus.Ready) From 1dd91178b3ecef380fccbd1d52124d4a5f0eba98 Mon Sep 17 00:00:00 2001 From: jbriones95 <108902016+jbriones95@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:06:17 -0600 Subject: [PATCH 4/5] Squashed 'third_party/light-keyboard/' content from commit 1755571 git-subtree-dir: third_party/light-keyboard git-subtree-split: 1755571b1d3353ecd3c6b68018079903ca4e389b --- .github/workflows/pr-check.yml | 40 + .gitignore | 19 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 11 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 20 + .idea/inspectionProfiles/Project_Default.xml | 61 ++ .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + CODE_OF_CONDUCT.md | 92 +++ CONTRIBUTING.md | 35 + LICENSE | 21 + README.md | 53 ++ app/.gitignore | 1 + app/build.gradle.kts | 55 ++ app/proguard-rules.pro | 21 + .../lp3keyboard/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 41 + .../thelightphone/lp3keyboard/IMEService.kt | 233 ++++++ .../lp3keyboard/LayoutPreferences.kt | 34 + .../LifecycleInputMethodService.kt | 43 + .../thelightphone/lp3keyboard/MainActivity.kt | 116 +++ .../res/drawable/ic_launcher_background.xml | 170 ++++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-night/themes.xml | 16 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 16 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + app/src/main/res/xml/method.xml | 4 + .../lp3keyboard/ExampleUnitTest.kt | 17 + build.gradle.kts | 5 + gradle.properties | 17 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 37 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 ++++++ gradlew.bat | 94 +++ jitpack.yml | 2 + settings.gradle.kts | 27 + ui/.gitignore | 2 + ui/build.gradle.kts | 97 +++ ui/consumer-rules.pro | 0 ui/proguard-rules.pro | 21 + .../lp3Keyboard/ui/ExampleInstrumentedTest.kt | 24 + ui/src/main/AndroidManifest.xml | 4 + .../lp3Keyboard/ui/HardwareKeyboardInput.kt | 127 +++ .../lp3Keyboard/ui/Lp3Keyboard.kt | 741 ++++++++++++++++++ .../ui/Lp3KeyboardLayoutCapture.kt | 66 ++ .../lp3Keyboard/ui/Lp3KeyboardView.kt | 82 ++ .../lp3Keyboard/ui/Lp3KeyboardWrapper.kt | 156 ++++ .../com/thelightphone/lp3Keyboard/ui/Style.kt | 92 +++ .../com/thelightphone/lp3Keyboard/ui/Utils.kt | 22 + .../lp3Keyboard/ui/layout/BeAzerty.kt | 125 +++ .../lp3Keyboard/ui/layout/EnColemak.kt | 136 ++++ .../lp3Keyboard/ui/layout/EnQwerty.kt | 125 +++ .../lp3Keyboard/ui/layout/EnShared.kt | 172 ++++ .../lp3Keyboard/ui/layout/FrAzerty.kt | 125 +++ .../ui/layout/Lp3KeyboardLayouts.kt | 107 +++ .../ui/viewmodel/BeAzertyViewModel.kt | 39 + .../ui/viewmodel/EnBaseViewModel.kt | 272 +++++++ .../ui/viewmodel/EnColemakViewModel.kt | 39 + .../ui/viewmodel/EnQwertyViewModel.kt | 39 + .../ui/viewmodel/FrAzertyViewModel.kt | 39 + .../ui/viewmodel/Lp3KeyboardViewModel.kt | 53 ++ ui/src/main/res/drawable/back_lp3.xml | 10 + ui/src/main/res/drawable/caps_lp3.xml | 16 + ui/src/main/res/drawable/down_lp3.xml | 10 + ui/src/main/res/drawable/microphone_lp3.xml | 14 + ui/src/main/res/drawable/return_lp3.xml | 10 + ui/src/main/res/drawable/smile.xml | 20 + ui/src/main/res/drawable/up_lp3.xml | 10 + .../lp3Keyboard/ui/EnQwertyViewModelTest.kt | 55 ++ .../lp3Keyboard/ui/ExampleUnitTest.kt | 17 + 91 files changed, 4614 insertions(+) create mode 100644 .github/workflows/pr-check.yml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt create mode 100644 app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt create mode 100644 app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt create mode 100644 app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/method.xml create mode 100644 app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 jitpack.yml create mode 100644 settings.gradle.kts create mode 100644 ui/.gitignore create mode 100644 ui/build.gradle.kts create mode 100644 ui/consumer-rules.pro create mode 100644 ui/proguard-rules.pro create mode 100644 ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt create mode 100644 ui/src/main/AndroidManifest.xml create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt create mode 100644 ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt create mode 100644 ui/src/main/res/drawable/back_lp3.xml create mode 100644 ui/src/main/res/drawable/caps_lp3.xml create mode 100644 ui/src/main/res/drawable/down_lp3.xml create mode 100644 ui/src/main/res/drawable/microphone_lp3.xml create mode 100644 ui/src/main/res/drawable/return_lp3.xml create mode 100644 ui/src/main/res/drawable/smile.xml create mode 100644 ui/src/main/res/drawable/up_lp3.xml create mode 100644 ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt create mode 100644 ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 00000000..48791c71 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,40 @@ +name: PR Check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Gradle check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run check + env: + GH_PACKAGES_USER: ${{ github.actor }} + GH_PACKAGES_TOKEN: ${{ secrets.GH_CI_TOKEN }} + run: ./gradlew check --stacktrace diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3be3c279 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle +/local.properties +/.idea/ +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/markdown.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/vcs.xml +.kotlin/errors +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..ca16a995 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 00000000..91f95584 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..6f6457b4 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..7061a0d6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..b2c751a3 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ecb209e0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,92 @@ + +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyoneโ€™s personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. + + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, **tag us (@lightteam) wherever it occurs, or reach out to support@thelightphone.com.** + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. + + +## Addressing and Repairing Harm + +**** + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. + + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozillaโ€™s code of conduct team](https://github.com/mozilla/inclusion). + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2121f068 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing + +**The [code of conduct](CODE_OF_CONDUCT.md) applies to all contributions, please go check that out first.** + +### Limitations +While the software in this library is fully open source, it is depended upon by the Light Phone's existing products. Above all else, we need to maintain compatibility with those products so we can continue to deliver safe, timely, and functional updates to our customers. We are excited to pull more closed code from those products into our open repositories, but it will take time. Ultimately, this means we are currently not interested in certain types of contributions from the community: +* Public API changes +* Additional/updated third-party dependencies (we'll do our best to stay up-to-date) +* Meaningful architectural changes + +### Welcome Contributions + +We expect contributions to come in the form of GitHub [issues](https://github.com/lightphone/light-keyboard/issues) and [pull requests](https://github.com/lightphone/light-keyboard/pulls). Not every issue requires a pull request, but we will close any pull requests that are not associated with an existing issue. If you are interested in submitting code changes for your issue, please state that clearly. Someone from the Light team will explicitly indicate on an issue that we would welcome a relevant PR. **We reserve the right to politely refuse any proposed work. If having your work merged is important to you, please wait until we give a green light on your issue!** + +We expect _all_ modules in this repository to compile, and _all_ tests to pass for each PR. We will have an automated check that runs on GitHub, but to save time/resources, **please** check that this is true before opening your PR. For this repo, you can run `./gradlew check` in the root directory. + +Types of issues we are excited to receive: +* **Bug Reports** (something does not work as it is intended to) + * Please include the commit on which you are experiencing the issue, a description, and detailed reproduction steps. +* **Feature Requests** ("it would be helpful if this software could also do `X`") + * This includes new languages/layouts! + * New features should be relevant to a meaningful percentage of Light Phone users / consumers of this software. We reserve the right to make the final call on whether or not this is true for your issue. +* **Security Issues** (something in this software might allow a bad actor to degrade a Light Phone user's experience or violate their privacy) +* **_Material_ Performance Improvements** (something in this software is actively degrading a Light Phone user's experience, or is egregiously consuming resources) + +### AI/LLM Policy +(Adapted from [Astral's](https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)) + +We like talking to _people_! + +- We expect all communication in this repository to come from a human. That includes issue/PR descriptions, comments, and replies. If you are a non-native English speaker using an LLM to translate for you, we would be grateful if you included your original content alongside the translation. +- We expect you to be able to explain any proposed code changes in your own words. +- We find that code comments produced by LLMs tend to be overly verbose and/or specific to your dev session. Please delete them, or if you think they're genuinely useful, make sure they are brief and in your voice. +- **You are responsible for any code or other communication that comes from your account**. +- **We (the humans on the Light dev team) are responsible for any code that gets merged.** diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..7b3eef3f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Light Phone + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..d6f58279 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# LPIII Keyboard + +A Compose implementation of the Light Phone's keyboard. To be used in LightOS, community tools, and/or as an Android system keyboard. + +**Note that as of July 1, 2026, public releases of LightOS are not yet using this as the embedded keyboard. Coming soon!** + +If you'd like to contribute/file issues, please read [CONTRIBUTING.md](CONTRIBUTING.md). For general questions/comments about the keyboard, please head to our [discussions](https://github.com/orgs/lightphone/discussions/categories/keyboard) page. + +### Layouts + +Currently, only English/QWERTY is supported. We want to add more languages/layouts as soon as possible. Please reach out if there are any you are particularly excited about! + +## Usage + +The `app` module wraps the keyboard into an Android IME app, which can be installed on any Android device + +The `ui` module is an Android library that contains all the actual keyboard UI code: + +Use the [Lp3Keyboard](ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt) composable for "embedded" usage (used in LightOS with some auxiliary UI around it) +```kotlin +@Composable +fun Lp3Keyboard( + layout: Layout, + options: KeyboardOptions, + callback: Lp3KeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback<*>? +) +``` + +Use the [Lp3KeyboardWrapper](ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt) composable for a self-contained version (includes a dismiss button) +```kotlin +@Composable +fun Lp3KeyboardWrapper( + layout: Layout, + keyboardOptions: KeyboardOptions, + layoutOptions: LayoutOptions, + callback: Lp3KeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback<*>? +) +``` + +Use the [Lp3RawKeyboardView](ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt) view for mixing in with classic Android views in a Java environment +```kotlin +open class Lp3RawKeyboardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) +``` + +Use the [Lp3KeyboardView](ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt) view for mixing in with classic Android views in Kotlin +```kotlin +class Lp3RawKeyboardView(context: Context, private val viewModel: Lp3KeyboardViewModel) +``` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..d2afd98c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.thelightphone.lp3keyboard" + compileSdk = 36 + + defaultConfig { + applicationId = "com.thelightphone.lp3keyboard" + minSdk = 33 + targetSdk = 36 + versionCode = 1 + versionName = providers.gradleProperty("projectVersion").get() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.activity.compose) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + implementation(libs.androidx.lifecycle.service) + implementation(libs.androidx.lifecycle.runtime.ktx) + api(project(":ui")) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..89cc09ff --- /dev/null +++ b/app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.thelightphone.lp3keyboard + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.thelightphone.lp3keyboard", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..05ba795f --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt b/app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt new file mode 100644 index 00000000..7d04f394 --- /dev/null +++ b/app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt @@ -0,0 +1,233 @@ +package com.thelightphone.lp3keyboard + +import android.content.SharedPreferences +import android.os.Vibrator +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardView +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.layout.LayoutRegistryItem +import com.thelightphone.lp3Keyboard.ui.layout.buildRootViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3RepeatableKeyboardCallback + +class IMEService : LifecycleInputMethodService(), + ViewModelStoreOwner, + SavedStateRegistryOwner, + Lp3RepeatableKeyboardCallback { + + private var renderedLayout: LayoutRegistryItem? = null + private var viewModel: Lp3KeyboardViewModel<*>? = null + + private var layoutPrefs: SharedPreferences? = null + private val layoutChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == LayoutPreferences.KEY_ACTIVE_LAYOUT) { + refreshLayoutIfNeeded() + } + } + + private fun refreshLayoutIfNeeded() { + if (LayoutPreferences.getActiveLayout(this) != renderedLayout) { + setInputView(onCreateInputView()) + } + } + + private fun buildViewModel(layout: LayoutRegistryItem): Lp3KeyboardViewModel<*> { + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val dummySwipeCallback = object : Lp3KeyboardSwipeCallback {} + return layout.buildRootViewModel( + this@IMEService, + dummySwipeCallback, + haptic = ::tick + ) as T + } + } + // Key by the layout's uniqueId so each layout gets its own retained ViewModel instance. + return ViewModelProvider(store, factory)[layout.uniqueId, ViewModel::class.java] + as Lp3KeyboardViewModel<*> + } + + override fun onCreateInputView(): View { + val layout = LayoutPreferences.getActiveLayout(this) + val vm = buildViewModel(layout) + renderedLayout = layout + viewModel = vm + + val view = Lp3KeyboardView( + context = this, + viewModel = vm, + // don't need to remap since no external keyboard + remapKeyCode = null + ).apply { + // don't need the keyboard view itself ot handle external keys, Android inputs will do it + handleHardwareKeyboardInput = false + } + setCandidatesViewShown(false) + window?.window?.let { + it.decorView.apply { + setViewTreeLifecycleOwner(this@IMEService) + setViewTreeViewModelStoreOwner(this@IMEService) + setViewTreeSavedStateRegistryOwner(this@IMEService) + } + } + return view + } + + override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { + super.onStartInputView(info, restarting) + refreshLayoutIfNeeded() + } + + override fun onCreate() { + super.onCreate() + savedStateRegistryController.performRestore(null) + layoutPrefs = LayoutPreferences.registerOnChange(this, layoutChangeListener) + } + + override fun onDestroy() { + layoutPrefs?.unregisterOnSharedPreferenceChangeListener(layoutChangeListener) + store.clear() + super.onDestroy() + } + + override val viewModelStore: ViewModelStore + get() = store + override val lifecycle: Lifecycle + get() = dispatcher.lifecycle + + private val store = ViewModelStore() + private val vibrator by lazy { getSystemService(Vibrator::class.java) } + + private fun tick() { + // 50ms feels good on LP3, other device motors may allow faster buzz + vibrator.vibrate(50) + } + + private val savedStateRegistryController = SavedStateRegistryController.create(this) + + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onWindowHidden() { + super.onWindowHidden() + viewModel?.cancelHeldKeys() + } + + override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { + super.onStartInput(attribute, restarting) + updateCapsMode() + } + + private fun updateCapsMode() { + val ic = currentInputConnection ?: return + val ei = currentInputEditorInfo ?: return + // might be set if the TextField is set to capitalize sentence starts, for example + val caps = ic.getCursorCapsMode(ei.inputType) + viewModel?.setCapsMode(caps != 0) + } + + override fun onKeyPressed(code: Int) { + } + + override fun onSubmitWord(word: CharSequence) { + currentInputConnection?.commitText("$word ", 1) + } + + override fun onSpecialKeyPressed(key: SpecialKey) { + when (key) { + SpecialKey.Space -> { + currentInputConnection?.commitText(" ", 1) + updateCapsMode() + } + + else -> {} + } + } + + override fun onKeyReleased(code: Int) { + val text = buildString { appendCodePoint(code) } + currentInputConnection?.commitText(text, 1) + updateCapsMode() + } + + override fun onSpecialKeyReleased(key: SpecialKey) { + when (key) { + SpecialKey.Backspace -> { + val ic = currentInputConnection ?: return + val before = ic.getTextBeforeCursor(1, 0) + val charsToDelete = + if (!before.isNullOrEmpty() && Character.isLowSurrogate(before[0])) 2 else 1 + ic.deleteSurroundingText(charsToDelete, 0) + updateCapsMode() + } + + SpecialKey.Return -> { + currentInputConnection?.commitText("\n", 1) + } + + SpecialKey.Close -> { + requestHideSelf(0) + } + + else -> {} + } + } + + override fun onKeyLongPressed(code: Int) { + } + + private fun deletePrecedingWord() { + val ic = currentInputConnection ?: return + // Get text before cursor to find the word boundary (max 100 chars long) + val before = ic.getTextBeforeCursor(100, 0) ?: return + val trimmed = before.trimEnd() + val lastSpace = trimmed.indexOfLast { it.isWhitespace() } + // Delete from cursor back to start of word (including trailing spaces) + val charsToDelete = before.length - (if (lastSpace >= 0) lastSpace + 1 else 0) + ic.deleteSurroundingText(charsToDelete, 0) + updateCapsMode() + } + + override fun onSpecialKeyLongPressed(key: SpecialKey) { + when (key) { + SpecialKey.Backspace -> { + deletePrecedingWord() + } + + else -> {} + } + } + + override fun onKeyRepeated(code: Int) { + onKeyReleased(code) + } + + override fun onSpecialKeyRepeated(specialKey: SpecialKey) { + when (specialKey) { + SpecialKey.Space -> { + currentInputConnection?.commitText(" ", 1) + updateCapsMode() + } + + SpecialKey.Backspace -> { + deletePrecedingWord() + } + + else -> {} + } + } +} diff --git a/app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt b/app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt new file mode 100644 index 00000000..ca1da7ae --- /dev/null +++ b/app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt @@ -0,0 +1,34 @@ +package com.thelightphone.lp3keyboard + +import android.content.Context +import com.thelightphone.lp3Keyboard.ui.layout.LayoutRegistryItem + +/** + * Persistent storage for the keyboard app + * Right now, values in here only affect the android system keyboard, NOT those embedded in + * LightOS/community tools. + */ +object LayoutPreferences { + private const val PREFS_NAME = "lp3_keyboard_prefs" + const val KEY_ACTIVE_LAYOUT = "active_layout_id" + + private val DEFAULT_LAYOUT = LayoutRegistryItem.EnQwerty + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getActiveLayout(context: Context): LayoutRegistryItem { + val id = prefs(context).getString(KEY_ACTIVE_LAYOUT, null) + return LayoutRegistryItem.entries.firstOrNull { it.uniqueId == id } ?: DEFAULT_LAYOUT + } + + fun setActiveLayout(context: Context, item: LayoutRegistryItem) { + prefs(context).edit().putString(KEY_ACTIVE_LAYOUT, item.uniqueId).apply() + } + + fun registerOnChange( + context: Context, + listener: android.content.SharedPreferences.OnSharedPreferenceChangeListener, + ): android.content.SharedPreferences = + prefs(context).also { it.registerOnSharedPreferenceChangeListener(listener) } +} diff --git a/app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt b/app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt new file mode 100644 index 00000000..55ab4c4f --- /dev/null +++ b/app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt @@ -0,0 +1,43 @@ +package com.thelightphone.lp3keyboard + +import android.content.Intent +import android.inputmethodservice.InputMethodService +import androidx.annotation.CallSuper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ServiceLifecycleDispatcher + +// From https://github.com/THEAccess/compose-keyboard-ime + +abstract class LifecycleInputMethodService : InputMethodService(), LifecycleOwner { + + protected val dispatcher = ServiceLifecycleDispatcher(this) + + @CallSuper + override fun onCreate() { + dispatcher.onServicePreSuperOnCreate() + super.onCreate() + } + + override fun onBindInput() { + super.onBindInput() + dispatcher.onServicePreSuperOnBind() + } + + + // this method is added only to annotate it with @CallSuper. + // In usual service super.onStartCommand is no-op, but in LifecycleService + // it results in mDispatcher.onServicePreSuperOnStart() call, because + // super.onStartCommand calls onStart(). + @CallSuper + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId) + } + + @CallSuper + override fun onDestroy() { + dispatcher.onServicePreSuperOnDestroy() + super.onDestroy() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt b/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt new file mode 100644 index 00000000..704a5ed9 --- /dev/null +++ b/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt @@ -0,0 +1,116 @@ +package com.thelightphone.lp3keyboard + +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.Button +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.layout.LayoutRegistryItem + +// Based on https://github.com/THEAccess/compose-keyboard-ime + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + Options() + } + } +} + +@Composable +fun Options() { + Column( + modifier = Modifier + .systemBarsPadding() + .padding(16.dp) + .background(Color.White) + .fillMaxWidth(), + ) { + val ctx = LocalContext.current + Text(text = "LP3 Keyboard") + val (text, setValue) = remember { mutableStateOf(TextFieldValue("Try here")) } + Spacer(modifier = Modifier.height(16.dp)) + Button(modifier = Modifier.fillMaxWidth(), onClick = { + ctx.startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)) + }) { + Text(text = "1. Enable IME") + } + Spacer(modifier = Modifier.height(16.dp)) + Button(modifier = Modifier.fillMaxWidth(), onClick = { + val imm = ctx.getSystemService(android.view.inputmethod.InputMethodManager::class.java) + imm.showInputMethodPicker() + }) { + Text(text = "2. Select IME") + } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "3. Choose layout") + LayoutPicker() + Spacer(modifier = Modifier.height(16.dp)) + TextField( + value = text, + onValueChange = setValue, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + } +} + +@Composable +fun LayoutPicker() { + val ctx = LocalContext.current + var selected by remember { mutableStateOf(LayoutPreferences.getActiveLayout(ctx)) } + Column(modifier = Modifier.fillMaxWidth()) { + LayoutRegistryItem.entries.forEach { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = item == selected, + onClick = { + selected = item + LayoutPreferences.setActiveLayout(ctx, item) + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = item == selected, + // Click is handled by the row's selectable modifier above. + onClick = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = item.label) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..57e36378 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..01b761b0 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Lp3Keyboard + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..a85b6036 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..4df92558 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/method.xml b/app/src/main/res/xml/method.xml new file mode 100644 index 00000000..f181e9d5 --- /dev/null +++ b/app/src/main/res/xml/method.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt b/app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt new file mode 100644 index 00000000..678f0e20 --- /dev/null +++ b/app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.thelightphone.lp3keyboard + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..41b070ae --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..2a7ee45a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +android.useAndroidX=true +projectVersion=0.0.18 \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..6c1139ec --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..d059ce52 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,37 @@ +[versions] +agp = "8.2.2" +coreKtx = "1.12.0" +kotlin = "2.0.0" +junit = "4.13.2" +junitVersion = "1.3.0" +compose-bom = "2025.05.00" +activity-compose = "1.9.3" +espressoCore = "3.5.1" +appcompat = "1.6.1" +material = "1.11.0" +lifecycle = "2.8.7" +mockk = "1.13.13" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-material = { group = "androidx.compose.material", name = "material" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "lifecycle" } +androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3fc8929a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Tue Mar 31 13:41:07 EDT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..ef07e016 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 00000000..1e41e00b --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,2 @@ +jdk: + - openjdk17 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..1fd07e36 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Lp3Keyboard" +include(":app") +include(":ui") diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..9156f19a --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,2 @@ +/build +/src/main/res/font \ No newline at end of file diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts new file mode 100644 index 00000000..9e254ec3 --- /dev/null +++ b/ui/build.gradle.kts @@ -0,0 +1,97 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + `maven-publish` +} + +// for publishing +val uiVersion = providers.gradleProperty("projectVersion").get() + +android { + namespace = "com.thelightphone.lp3Keyboard.ui" + compileSdk = 34 + + defaultConfig { + minSdk = 33 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +android.publishing { + singleVariant("release") { + withSourcesJar() + } +} + +val localProperties = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) } +} + +publishing { + // TODO signing config + publications { + register("release") { + groupId = "com.thelightphone.lp3keyboard" + artifactId = "ui" + version = uiVersion + + afterEvaluate { + from(components["release"]) + } + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/lightphone/light-keyboard") + credentials { + username = localProperties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") + password = localProperties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") + } + } + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.compose.foundation) + api(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel) + testImplementation(libs.junit) + testImplementation(libs.mockk) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} + +// Suppress Gradle module metadata so consumers use the POM exclusively. +tasks.withType { + enabled = false +} diff --git a/ui/consumer-rules.pro b/ui/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/ui/proguard-rules.pro b/ui/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/ui/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt b/ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..9abcacca --- /dev/null +++ b/ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.thelightphone.lp3Keyboard.ui + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.thelightphone.lp3Keyboard.ui.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5dd7fb61 --- /dev/null +++ b/ui/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt new file mode 100644 index 00000000..5eee4f7b --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt @@ -0,0 +1,127 @@ +package com.thelightphone.lp3Keyboard.ui + +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import androidx.compose.foundation.focusable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalView + +/** + * Key codes reported by the hardware input on an LP3 + */ +enum class LightDeviceKeys( + val keyCode: Int +) { + VolumeUp(24), + VolumeDown(25), + ShutterPressed(27), + ShutterHalfPressed(80), + RotaryTurnUp(317), + RotaryTurnDown(318), + RotaryButtonPress(319) + ; + companion object { + val mapping = entries.associateBy { it.keyCode } + } +} + +/** + * Unfortunately, the Android build running on LP3s uses a keyboard layout that remaps + * common keys (like 't' and 'r') to behave like LP3-specific hardware buttons. This is likely + * leftover from early development -> external keyboards weren't really a considered use case + * + * So here we re-re-map events from EXTERNAL HID devices. Shouldn't have much of an impact, though + * if your keyboard produces WHEEL_CW/CCW events, they might come through as T's and R's. + */ +fun lightOsRemap(nativeKeyEvent: KeyEvent): Int { + val device = InputDevice.getDevice(nativeKeyEvent.deviceId) + if (device == null || !device.isExternal) return nativeKeyEvent.keyCode + return when (LightDeviceKeys.mapping[nativeKeyEvent.keyCode]) { + LightDeviceKeys.RotaryTurnUp -> KeyEvent.KEYCODE_R + LightDeviceKeys.RotaryTurnDown -> KeyEvent.KEYCODE_T + LightDeviceKeys.RotaryButtonPress -> KeyEvent.KEYCODE_F8 + LightDeviceKeys.ShutterPressed -> KeyEvent.KEYCODE_RIGHT_BRACKET + LightDeviceKeys.ShutterHalfPressed -> KeyEvent.KEYCODE_NUMPAD_2 + // don't remap these + LightDeviceKeys.VolumeUp, LightDeviceKeys.VolumeDown, null -> nativeKeyEvent.keyCode + } +} + +// Routes key events from an external (Bluetooth/USB) hardware keyboard into [callback]. +@Composable +fun Modifier.hardwareKeyboardInput( + callback: Lp3KeyboardCallback, + remapKeyCode: ((KeyEvent) -> Int)? = ::lightOsRemap +): Modifier { + val view = LocalView.current + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + view.isFocusable = true + view.isFocusableInTouchMode = true + if (!view.isFocused) { + view.requestFocus() + } + focusRequester.requestFocus() + } + return this + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { keyEvent -> + val native = keyEvent.nativeKeyEvent + val keyCode = remapKeyCode?.invoke(native) ?: native.keyCode + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) return@onKeyEvent true + + val specialKey = when (keyCode) { + KeyEvent.KEYCODE_DEL -> SpecialKey.Backspace + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> SpecialKey.Return + else -> null + } + if (specialKey != null) { + when (keyEvent.type) { + KeyEventType.KeyDown -> { + if (native.repeatCount == 0) { + callback.onSpecialKeyPressed(specialKey) + } + true + } + KeyEventType.KeyUp -> { + callback.onSpecialKeyReleased(specialKey) + true + } + else -> false + } + } else { + // If we didn't remap, native.unicodeChar already reflects meta state (shift, + // etc). If we did, it's still resolved for the *original* (wrong) keyCode, so + // look up the remapped keyCode's character ourselves instead. + val codePoint = if (keyCode == native.keyCode) { + native.unicodeChar.takeIf { it != 0 } + } else { + KeyCharacterMap.load(native.deviceId).get(keyCode, native.metaState) + .takeIf { it != 0 } + } ?: return@onKeyEvent false + when (keyEvent.type) { + KeyEventType.KeyDown -> { + if (native.repeatCount == 0) { + callback.onKeyPressed(codePoint) + } + true + } + KeyEventType.KeyUp -> { + callback.onKeyReleased(codePoint) + true + } + else -> false + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt new file mode 100644 index 00000000..b3b5ee4c --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt @@ -0,0 +1,741 @@ +package com.thelightphone.lp3Keyboard.ui + +import android.os.SystemClock +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty +import com.thelightphone.lp3Keyboard.ui.layout.EnShared +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import com.thelightphone.lp3Keyboard.ui.layout.SwipeConfig +import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +enum class SpecialKey { + UpCase, + DownCase, + Backspace, + Space, + Letters, + Numbers, + Symbols, + Emojis, + Submit, + Close, + Voice, + Return +} + +interface Lp3KeyboardCallback { + fun onKeyPressed(code: Int) + fun onSpecialKeyPressed(key: SpecialKey) + fun onKeyReleased(code: Int) + fun onSpecialKeyReleased(key: SpecialKey) + fun onKeyLongPressed(code: Int) + fun onSpecialKeyLongPressed(key: SpecialKey) + fun onSubmitWord(word: CharSequence) + + // Pointer left the key bounds before lifting. Clean up / do not treat as tap + fun onKeyCancelled(code: Int) = onKeyReleased(code) +} + +interface Lp3KeyboardSwipeCallback { + fun onSwipeLayoutReady(letters: String, cx: FloatArray, cy: FloatArray) = Unit + fun onSwipeStarted() = Unit + fun onSwipeCompleted(x: FloatArray, y: FloatArray, t: FloatArray): List = + emptyList() + fun getWordForResult(swipeResult: ResultType): CharSequence? = null +} + +const val LP3_KEYBOARD_HEIGHT_DP = 164 +const val STANDARD_KEY_WIDTH_DP = 35 +const val ICON_KEY_WIDTH_DP = STANDARD_KEY_WIDTH_DP + 14 +const val MEDIUM_KEY_WIDTH_DP = STANDARD_KEY_WIDTH_DP + 8 +const val STANDARD_ROW_HEIGHT_DP = 44 +const val STANDARD_KEY_TEXT_SP = 25 +const val MINIMUM_SWIPE_DP = 40 +private const val SWIPE_TRAIL_FADE_MS = 350L +private const val SWIPE_TRAIL_WIDTH_DP = 6 + +private data class TrailPoint(val x: Float, val y: Float, val timeMs: Long) + +@Composable +fun Lp3Keyboard( + layout: Layout, + options: KeyboardOptions, + callback: Lp3KeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback<*>? +) { + val swipeConfig = layout.swipeConfig.takeIf { options.swipeEnabled } + // Pointer positions inside the swipe gesture are local to this Box, but the + // letter bounds reported via onGloballyPositioned/boundsInRoot are in the + // composition root's coordinate space. Track the Box's own root offset so the + // swipe handler can reconcile them. + val boxRootOffset = remember { mutableStateOf(Offset.Zero) } + // Live swipe trail. Points carry the uptime they were sampled at, so the + // Canvas can fade each segment independently. Points are pruned after the + // fade window elapses; the frame ticker idles when the list is empty. + val trailPoints = remember { mutableStateListOf() } + var nowMs by remember { mutableLongStateOf(0L) } + val trailColor = LocalKeyboardColors.current.foreground + // Reused across draws โ€” rewind() is cheap, allocating a new Path/SkPath + // every frame is not. + val swipePath = remember { Path() } + // Resolve the Akkurat family once and hand it to keys through a + // CompositionLocal. lightFontFamily scans SystemFonts.getAvailableFonts(), + // which we don't want to run per-key. + val context = LocalContext.current + val akkurat = remember(context) { lightFontFamily(context) } + + LaunchedEffect(Unit) { + while (true) { + // Idle until a gesture starts recording points. + snapshotFlow { trailPoints.isNotEmpty() }.filter { it }.first() + while (trailPoints.isNotEmpty()) { + withFrameNanos { /* tick the frame clock so we recompose */ } + nowMs = SystemClock.uptimeMillis() + // Clear the whole trail once the newest point has fully faded. + // While the gesture is active the newest point is constantly + // refreshed so this never trips; once the finger lifts, the + // trail fades together and disappears as a unit. + // trailPoints can be cleared concurrently by a new gesture + // starting (see the pointerInput block below) while we were + // suspended in withFrameNanos, so re-check before reading last(). + val newest = trailPoints.lastOrNull() ?: break + val newestAge = nowMs - newest.timeMs + if (newestAge > SWIPE_TRAIL_FADE_MS) trailPoints.clear() + } + } + } + Box( + Modifier + .fillMaxWidth() + .height(LP3_KEYBOARD_HEIGHT_DP.dp) + .background(LocalKeyboardColors.current.background) + .onGloballyPositioned { boxRootOffset.value = it.positionInRoot() } + .then( + if (swipeConfig != null) { + Modifier.pointerInput(swipeConfig) { + val minSwipePx = MINIMUM_SWIPE_DP.dp.toPx() + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val startTime = down.uptimeMillis + val xs = ArrayList() + val ys = ArrayList() + val ts = ArrayList() + // Pointer events on Android are already on + // SystemClock.uptimeMillis, which is the same clock + // the fade ticker reads โ€” so we can store + // change.uptimeMillis directly for the trail. + val pointTimes = ArrayList() + xs.add(down.position.x) + ys.add(down.position.y) + ts.add(0f) + pointTimes.add(startTime) + // Clear any leftover trail from the previous gesture. + // Do NOT seed it yet โ€” taps jitter a few pixels and + // would render as a dot. We hold the trail back + // until displacement crosses the swipe threshold, + // then backfill so the drawn line starts at the + // touch-down position. + trailPoints.clear() + var minX = down.position.x + var maxX = down.position.x + var minY = down.position.y + var maxY = down.position.y + var swipeStarted = false + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + ?: break + val p = change.position + xs.add(p.x); ys.add(p.y) + ts.add((change.uptimeMillis - startTime).toFloat()) + pointTimes.add(change.uptimeMillis) + if (p.x < minX) minX = p.x + if (p.x > maxX) maxX = p.x + if (p.y < minY) minY = p.y + if (p.y > maxY) maxY = p.y + if (!swipeStarted) { + val displacementPx = maxOf(maxX - minX, maxY - minY) + if (displacementPx >= minSwipePx) { + swipeCallback?.onSwipeStarted() + swipeStarted = true + // Backfill the trail with everything collected so far + // because we only want to start drawing the trail when we're + // definitely in a swipe + for (i in xs.indices) { + trailPoints.add(TrailPoint(xs[i], ys[i], pointTimes[i])) + } + } + } else { + trailPoints.add(TrailPoint(p.x, p.y, change.uptimeMillis)) + } + if (!change.pressed) break + } + + if (swipeCallback == null) return@awaitEachGesture + val finalDisplacement = maxOf(maxX - minX, maxY - minY) + if (finalDisplacement < minSwipePx) return@awaitEachGesture + val rect = swipeConfig.letterBoundsRect() ?: return@awaitEachGesture + val w = rect.width.coerceAtLeast(1f) + val h = rect.height.coerceAtLeast(1f) + // Lift Box-local touch coordinates into root space before + // normalizing against the root-space letter rect. + val ox = boxRootOffset.value.x + val oy = boxRootOffset.value.y + val nx = FloatArray(xs.size) { (xs[it] + ox - rect.left) / w } + val ny = FloatArray(ys.size) { (ys[it] + oy - rect.top) / h } + val nt = FloatArray(ts.size) { ts[it] } + swipeCallback.onSwipeCompleted(nx, ny, nt) + } + } + } else Modifier + ) + ) { + Column(Modifier.fillMaxSize().padding(top = 4.dp).align(Alignment.Center)) { + CompositionLocalProvider(LocalAkkuratFamily provides akkurat) { + with(layout) { Render(options, callback) } + } + } + if (swipeConfig != null) { + Canvas(Modifier.fillMaxSize().clipToBounds()) { + if (trailPoints.size < 2) return@Canvas + // Whole-trail alpha keyed to the newest point's age + // tried "comet" effect but overlapping butts looked like dots + val newestAge = (nowMs - trailPoints.last().timeMs).coerceAtLeast(0L) + val alpha = (1f - newestAge.toFloat() / SWIPE_TRAIL_FADE_MS).coerceIn(0f, 1f) + if (alpha <= 0f) return@Canvas + swipePath.rewind() + swipePath.moveTo(trailPoints[0].x, trailPoints[0].y) + for (i in 1 until trailPoints.size) { + swipePath.lineTo(trailPoints[i].x, trailPoints[i].y) + } + drawPath( + path = swipePath, + color = trailColor.copy(alpha = alpha), + style = Stroke( + width = SWIPE_TRAIL_WIDTH_DP.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + } + + LaunchedEffect(swipeConfig) { + swipeConfig.boundsFlow.first() + swipeConfig.deriveLayout()?.let { (letters, cx, cy) -> + swipeCallback?.onSwipeLayoutReady(letters, cx, cy) + } + } + } + } +} + +fun Modifier.keyInput( + inputKey: Any?, + onPressed: () -> Unit, + onReleased: () -> Unit, + onLongPressed: () -> Unit, + onPressedChanged: (Boolean) -> Unit, + onCancelled: () -> Unit = onReleased +) = pointerInput(inputKey) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false).also { it.consume() } + onPressedChanged(true) + onPressed() + // waitForUpOrCancellation returns null when the pointer leaves our + // bounds. It was a drag vs. a tap. Track which one so callers + // can suppress the IME commit while still cleaning up press state. + var up: PointerInputChange? = null + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + up = waitForUpOrCancellation()?.also { it.consume() } + } + } catch (_: PointerEventTimeoutCancellationException) { + onLongPressed() + up = waitForUpOrCancellation()?.also { it.consume() } + } + onPressedChanged(false) + if (up != null) onReleased() else onCancelled() + } +} + +@Composable +fun RowScope.IconKey( + @DrawableRes drawable: Int, + key: SpecialKey, + callback: Lp3KeyboardCallback, + enableKeyAnimation: Boolean, + modifier: Modifier = Modifier, + width: Dp = STANDARD_KEY_WIDTH_DP.dp +) { + var pressed by remember { mutableStateOf(false) } + val onPressed = remember(key, callback) { { callback.onSpecialKeyPressed(key) } } + val onReleased = remember(key, callback) { { callback.onSpecialKeyReleased(key) } } + val onLongPressed = remember(key, callback) { { callback.onSpecialKeyLongPressed(key) } } + Box( + modifier = Modifier + .width(width) + .fillMaxHeight() + .keyInput( + inputKey = key, + onPressed = onPressed, + onReleased = onReleased, + onLongPressed = onLongPressed, + onPressedChanged = { pressed = it } + ) + .then(modifier), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(drawable), + contentDescription = "TODO", + tint = LocalKeyboardColors.current.foreground, + modifier = Modifier.then( + if (enableKeyAnimation) { + Modifier.graphicsLayer { + val isPressed = pressed + scaleX = if (isPressed) 1.25f else 1f + scaleY = if (isPressed) 1.25f else 1f + translationY = if (isPressed) -12.dp.toPx() else 0f + } + } else { + Modifier + } + ) + ) + } +} + + +@Composable +fun RowScope.SpaceBar(callback: Lp3KeyboardCallback, width: Dp, enableKeyAnimation: Boolean) { + var pressed by remember { mutableStateOf(false) } + val onPressed = remember(callback) { { callback.onSpecialKeyPressed(SpecialKey.Space) } } + val onReleased = remember(callback) { { callback.onSpecialKeyReleased(SpecialKey.Space) } } + val onLongPressed = remember(callback) { { callback.onSpecialKeyLongPressed(SpecialKey.Space) } } + Box( + Modifier + .fillMaxHeight() + .width(width) + .padding(bottom = 6.dp) + .keyInput( + inputKey = Unit, + onPressed = onPressed, + onReleased = onReleased, + onLongPressed = onLongPressed, + onPressedChanged = { pressed = it } + ).then( + if (enableKeyAnimation) { + Modifier.graphicsLayer { + val isPressed = pressed + scaleX = if (isPressed) 1.1f else 1f + scaleY = if (isPressed) 1.1f else 1f + translationY = if (isPressed) -8.dp.toPx() else 0f + } + } else { + Modifier + } + ) + ) { + Box( + Modifier + .height(2.dp) + .background(LocalKeyboardColors.current.foreground) + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) + } +} + +@Composable +fun RowScope.Key( + char: Char, + callback: Lp3KeyboardCallback, + swipeConfig: SwipeConfig?, + enableKeyAnimation: Boolean, + override: SpecialKey? = null +) = Key(char.code, callback, swipeConfig, enableKeyAnimation, override) + +@Composable +fun RowScope.Key( + code: Int, + callback: Lp3KeyboardCallback, + swipeConfig: SwipeConfig?, + enableKeyAnimation: Boolean, + override: SpecialKey? = null, + width: Dp = STANDARD_KEY_WIDTH_DP.dp +) { + var pressed by remember { mutableStateOf(false) } + val label = remember(code) { buildString { appendCodePoint(code) } } + + val onPressed = remember(code, override, callback) { + override + ?.let { { callback.onSpecialKeyPressed(it) } } + ?: { callback.onKeyPressed(code) } + } + + val onReleased = remember(code, override, callback) { + override + ?.let { { callback.onSpecialKeyReleased(it) } } + ?: { callback.onKeyReleased(code) } + } + + val onLongPressed = remember(code, override, callback) { + override + ?.let { { callback.onSpecialKeyLongPressed(it) } } + ?: { callback.onKeyLongPressed(code) } + } + + // Drag-off (pointer leaves the key bounds): for letter keys this is the + // start of a potential swipe โ€” route to onKeyCancelled so the IME doesn't + // commit the character. Special-key overrides keep release semantics. + val onCancelled = remember(code, override, callback) { + override + ?.let { { callback.onSpecialKeyReleased(it) } } + ?: { callback.onKeyCancelled(code) } + } + + Box( + modifier = Modifier + .width(width) + .fillMaxHeight() + .then( + if (swipeConfig != null && override == null) { + Modifier.onGloballyPositioned { swipeConfig.report(code, it.boundsInRoot()) } + } else Modifier + ) + .keyInput( + inputKey = code, + onPressed = onPressed, + onReleased = onReleased, + onLongPressed = onLongPressed, + onPressedChanged = { pressed = it }, + onCancelled = onCancelled + ), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = LocalKeyboardColors.current.foreground, + fontFamily = LocalAkkuratFamily.current, + fontWeight = FontWeight.Normal, + fontSize = STANDARD_KEY_TEXT_SP.sp, + modifier = Modifier.then( + if (enableKeyAnimation) { + Modifier.graphicsLayer { + val isPressed = pressed + scaleX = if (isPressed) 1.25f else 1f + scaleY = if (isPressed) 1.25f else 1f + translationY = if (isPressed) -12.dp.toPx() else 0f + } + } else { + Modifier + } + ) + ) + } +} + +@Composable +fun RowScope.MultiLabelKey( + labelText: String, + key: SpecialKey, + callback: Lp3KeyboardCallback, + enableKeyAnimation: Boolean +) { + var pressed by remember { mutableStateOf(false) } + val onPressed = remember(key, callback) { { callback.onSpecialKeyPressed(key) } } + val onReleased = remember(key, callback) { { callback.onSpecialKeyReleased(key) } } + val onLongPressed = remember(key, callback) { { callback.onSpecialKeyLongPressed(key) } } + Box( + modifier = Modifier + .width(ICON_KEY_WIDTH_DP.dp) + .fillMaxHeight() + .keyInput( + inputKey = labelText, + onPressed = onPressed, + onReleased = onReleased, + onLongPressed = onLongPressed, + onPressedChanged = { pressed = it } + ), + contentAlignment = BiasAlignment(-0.2f, 0.2f) + ) { + Text( + text = labelText, + color = LocalKeyboardColors.current.foreground, + fontFamily = LocalAkkuratFamily.current, + fontWeight = FontWeight.Normal, + letterSpacing = 2.sp, + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.then( + if (enableKeyAnimation) { + Modifier.graphicsLayer { + val isPressed = pressed // state read happens at draw time + scaleX = if (isPressed) 1.25f else 1f + scaleY = if (isPressed) 1.25f else 1f + translationY = if (isPressed) -12.dp.toPx() else 0f + } + } else { + Modifier + } + ) + ) + } +} + +typealias Emoji = Int + +data class KeyboardOptions( + val emojis: List?, + val displayReturn: Boolean, + val displayVoice: Boolean, + val enableKeyAnimation: Boolean, + val swipeEnabled: Boolean +) + +data class LayoutOptions( + val displayCloseButton: Boolean +) + +@Composable +fun ColumnScope.DefaultRow( + height: Dp = STANDARD_ROW_HEIGHT_DP.dp, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(height), + horizontalArrangement = Arrangement.Center, + content = content + ) +} + + +@Composable +fun ColumnScope.FirstRow( + characters: String, + callback: Lp3KeyboardCallback, + swipeConfig: SwipeConfig?, + enableKeyAnimation: Boolean +) { + DefaultRow { + for (char in characters) { + Key(char, callback, swipeConfig, enableKeyAnimation) + } + } +} + +@Composable +fun ColumnScope.SecondRow( + characters: String, + callback: Lp3KeyboardCallback, + swipeConfig: SwipeConfig?, + enableKeyAnimation: Boolean +) { + // same style as first row on all keyboards + FirstRow(characters, callback, swipeConfig, enableKeyAnimation) +} + +@Composable +fun ColumnScope.ThirdRow( + characters: String, + callback: Lp3KeyboardCallback, + swipeConfig: SwipeConfig?, + keyboardOptions: KeyboardOptions, + leftButton: @Composable RowScope.() -> Unit +) { + DefaultRow { + leftButton() + if (characters.length == 5) { + // currently this row only has 5 or 7 chars, so add some space if there are 5 + Spacer(Modifier.width(MEDIUM_KEY_WIDTH_DP.dp)) + } + for (char in characters) { + Key(char, callback, swipeConfig, keyboardOptions.enableKeyAnimation) + } + if (characters.length == 5) { + Spacer(Modifier.width(STANDARD_KEY_WIDTH_DP.dp)) + } + IconKey( + R.drawable.back_lp3, + SpecialKey.Backspace, + callback, + keyboardOptions.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(10.dp).padding(start = 8.dp, bottom = 6.dp) + ) + } +} + +@Composable +fun ColumnScope.FinalRow( + options: KeyboardOptions, + callback: Lp3KeyboardCallback, + leftButton: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp) + .height((STANDARD_ROW_HEIGHT_DP - 20).dp), + horizontalArrangement = Arrangement.Center, + ) { + val iconKeyWidth = STANDARD_KEY_WIDTH_DP + 12 + leftButton() + if (!options.emojis.isNullOrEmpty()) { + IconKey( + R.drawable.smile, + SpecialKey.Emojis, + callback, + options.enableKeyAnimation, + width = iconKeyWidth.dp, + modifier = Modifier.padding(start = 5.dp, end = 6.5.dp).padding(end = 16.dp) + ) + } else { + Spacer(Modifier.width(iconKeyWidth.dp)) + } + SpaceBar(callback, 160.dp, options.enableKeyAnimation) + if (options.displayReturn) { + IconKey( + R.drawable.return_lp3, + SpecialKey.Return, + callback, + options.enableKeyAnimation, + width = iconKeyWidth.dp, + modifier = Modifier.padding(top = 4.dp, start = 20.dp, end = 0.dp) + ) + } else { + Spacer(Modifier.width(iconKeyWidth.dp)) + } + + if (options.displayVoice) { + IconKey( + R.drawable.microphone_lp3, + SpecialKey.Voice, + callback, + options.enableKeyAnimation, + width = iconKeyWidth.dp, + modifier = Modifier.padding(top = 2.dp, start = 12.dp, end = 4.dp) + ) + } else { + Spacer(Modifier.width(iconKeyWidth.dp)) + } + } +} + +internal val previewCallback = object : Lp3KeyboardCallback { + override fun onKeyPressed(code: Int) = Unit + override fun onSpecialKeyPressed(key: SpecialKey) = Unit + override fun onKeyReleased(code: Int) = Unit + override fun onSpecialKeyReleased(key: SpecialKey) = Unit + override fun onKeyLongPressed(code: Int) = Unit + override fun onSpecialKeyLongPressed(key: SpecialKey) = Unit + override fun onSubmitWord(word: CharSequence) = Unit +} + +@Preview(name = "Dark", widthDp = (1080 / 3), heightDp = (1240 / 3)) +@Composable +fun Lp3KeyboardDarkPreview() { + Lp3KeyboardTheme(DarkKeyboardColors) { + Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.fillMaxSize()) { + val keyboardOptions = KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = true + ) + val layoutOptions = LayoutOptions(displayCloseButton = true) + Lp3KeyboardWrapper( + EnShared.EmojiLayout, + keyboardOptions, + layoutOptions, + previewCallback, + null + ) + } + } +} + +@Preview(name = "Light", widthDp = (1080 / 3), heightDp = (1240 / 3)) +@Composable +fun Lp3KeyboardLightPreview() { + Lp3KeyboardTheme(LightKeyboardColors) { + Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.fillMaxSize()) { + val keyboardOptions = KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = true + ) + val layoutOptions = LayoutOptions(displayCloseButton = true) + Lp3KeyboardWrapper( + EnQwerty.UpperCaseLayout, + keyboardOptions, + layoutOptions, + previewCallback, + null + ) + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt new file mode 100644 index 00000000..27fb537b --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt @@ -0,0 +1,66 @@ +package com.thelightphone.lp3Keyboard.ui + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Rect +import com.thelightphone.lp3Keyboard.ui.layout.SwipeConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter + +/** + * Lets external code observe where letter keys land on screen, + * so it can build a normalized [letters, cx, cy] layout for a swipe decoder + * without hardcoding key sizes. + */ +abstract class Lp3KeyboardLayoutCapture(val letters: String) : SwipeConfig { + protected val letterBounds = mutableStateMapOf() + + override val boundsFlow: Flow + get() = snapshotFlow { letterBounds.size }.filter { it >= letters.length } + + /** + * Build [letters, cx, cy] normalized to the bounding box of all letter + * key rectangles. Returns null until every char in [letters] has reported + * a position. The same bounding box should normalize live swipe touch coordinates + * before they reach SwipeDecoder.recognize(). + */ + override fun deriveLayout(): Triple? { + val n = letters.length + val rectangles = Array(n) { letterBounds[letters[it].code] ?: return null } + var minX = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + for (r in rectangles) { + if (r.left < minX) minX = r.left + if (r.right > maxX) maxX = r.right + if (r.top < minY) minY = r.top + if (r.bottom > maxY) maxY = r.bottom + } + val w = (maxX - minX).coerceAtLeast(1f) + val h = (maxY - minY).coerceAtLeast(1f) + val cx = FloatArray(n) + val cy = FloatArray(n) + for (i in 0 until n) { + val r = rectangles[i] + cx[i] = ((r.left + r.right) / 2f - minX) / w + cy[i] = ((r.top + r.bottom) / 2f - minY) / h + } + return Triple(letters, cx, cy) + } + + /** + * Root-relative rectangle enclosing every letter key. Same coordinate space as + * Compose's onGloballyPositioned/boundsInRoot. + * Null until all letters have reported. + */ + override fun letterBoundsRect(): Rect? { + val keyRectangles = letters.map { letterBounds[it.code] ?: return null } + return Rect( + left = keyRectangles.minOf { it.left }, + top = keyRectangles.minOf { it.top }, + right = keyRectangles.maxOf { it.right }, + bottom = keyRectangles.maxOf { it.bottom } + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt new file mode 100644 index 00000000..72a7ba92 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt @@ -0,0 +1,82 @@ +package com.thelightphone.lp3Keyboard.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AbstractComposeView +import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis + +open class Lp3RawKeyboardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : AbstractComposeView(context, attrs) { + var displayEmojis: Boolean by mutableStateOf(false) + var callback: Lp3KeyboardCallback? by mutableStateOf(null) + var swipeCallback: Lp3KeyboardSwipeCallback<*>? by mutableStateOf(null) + var displayReturn: Boolean by mutableStateOf(false) + var displayVoice: Boolean by mutableStateOf(false) + var enableKeyAnimation: Boolean by mutableStateOf(true) + var swipeEnabled: Boolean by mutableStateOf(true) + var emojis: List? by mutableStateOf(defaultEmojis) + var layout: Layout by mutableStateOf(EnQwerty.LowerCaseLayout) + var darkMode: Boolean by mutableStateOf(true) + var handleHardwareKeyboardInput: Boolean by mutableStateOf(true) + + // by default, assume running on an LP3 + open fun remapKeyCode(keyEvent: KeyEvent): Int = lightOsRemap(keyEvent) + + @Composable + override fun Content() { + val cb = callback ?: return + Lp3KeyboardTheme(if (darkMode) DarkKeyboardColors else LightKeyboardColors) { + Box( + modifier = Modifier.then( + if (handleHardwareKeyboardInput) { + Modifier.hardwareKeyboardInput(cb, this::remapKeyCode) + } else { + Modifier + } + ) + ) { + Lp3Keyboard( + this@Lp3RawKeyboardView.layout, + KeyboardOptions( + emojis = if (displayEmojis) this@Lp3RawKeyboardView.emojis else emptyList(), + displayReturn = this@Lp3RawKeyboardView.displayReturn, + displayVoice = this@Lp3RawKeyboardView.displayVoice, + enableKeyAnimation = this@Lp3RawKeyboardView.enableKeyAnimation, + swipeEnabled = this@Lp3RawKeyboardView.swipeEnabled + ), + cb, + swipeCallback + ) + } + } + } +} + +class Lp3KeyboardView( + context: Context, + private val viewModel: Lp3KeyboardViewModel, + private val remapKeyCode: ((KeyEvent) -> Int)? = ::lightOsRemap +) : + AbstractComposeView(context) { + var darkMode: Boolean by mutableStateOf(true) + var handleHardwareKeyboardInput: Boolean by mutableStateOf(true) + + @Composable + override fun Content() { + Lp3KeyboardTheme(if (darkMode) DarkKeyboardColors else LightKeyboardColors) { + Lp3KeyboardWrapper(viewModel, handleHardwareKeyboardInput, remapKeyCode) + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt new file mode 100644 index 00000000..f77f1e56 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt @@ -0,0 +1,156 @@ +package com.thelightphone.lp3Keyboard.ui + +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty +import com.thelightphone.lp3Keyboard.ui.layout.EnShared +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis + +/* +For using the keyboard outside LightOS. LightOS adds additional UI surrounding the keyboard +that technically controls it. For example, when the Emoji keyboard is showing, LightOS inserts a +"close" button at the bottom that sets the keyboard back to "letters" when pressed. The Wrapper +composables provide a place to re-create that behavior when using this as a system keyboard. +Eventually, we will replace the custom UI in LightOS with this, so we have a single source of truth + */ + +@Composable +fun Lp3KeyboardWrapper( + viewModel: Lp3KeyboardViewModel<*>, + handleHardwareKeyboardInput: Boolean = true, + remapKeyCode: ((KeyEvent) -> Int)? = ::lightOsRemap +) { + val layout by viewModel.layoutFlow.collectAsState() + val keyboardOptions by viewModel.keyboardOptionsFlow.collectAsState() + val layoutOptions by viewModel.layoutOptionsFlow.collectAsState() + Lp3KeyboardWrapper( + layout, + keyboardOptions, + layoutOptions, + viewModel, + viewModel, + handleHardwareKeyboardInput, + remapKeyCode + ) +} + +@Composable +fun Lp3KeyboardWrapper( + layout: Layout, + keyboardOptions: KeyboardOptions, + layoutOptions: LayoutOptions, + callback: Lp3KeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback<*>?, + handleHardwareKeyboardInput: Boolean = true, + remapKeyCode: ((KeyEvent) -> Int)? = ::lightOsRemap, + additionalBottomHeight: Dp = 0.dp, + bottomBar: (@Composable () -> Unit)? = null, + onOverlayDismissed: (() -> Unit)? = null, + overlay: (@Composable () -> Unit)? = null, +) { + val colors = LocalKeyboardColors.current + val additionalHeight = maxOf(additionalBottomHeight, 36.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .height(LP3_KEYBOARD_HEIGHT_DP.dp + additionalHeight) + .background(colors.background) + .then( + if (handleHardwareKeyboardInput) { + Modifier.hardwareKeyboardInput(callback, remapKeyCode) + } else { + Modifier + } + ) + ) { + if (overlay != null) { + Box(Modifier.fillMaxWidth().height(LP3_KEYBOARD_HEIGHT_DP.dp)) { + overlay() + } + } else { + Spacer(Modifier.height(10.dp)) + Lp3Keyboard(layout, keyboardOptions, callback, swipeCallback) + } + Row( + Modifier.weight(1f).fillMaxWidth().background(colors.background), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Bottom + ) { + if (layoutOptions.displayCloseButton || overlay != null) { + Button( + onClick = { + if (onOverlayDismissed != null) { + onOverlayDismissed() + } else { + callback.onSpecialKeyReleased(SpecialKey.Close) + } + }, + contentPadding = PaddingValues(bottom = 10.dp, top = 4.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = colors.foreground, + ), + modifier = Modifier.height(28.dp) + ) { + Icon( + painterResource(R.drawable.down_lp3), + "Close" + ) + } + } else if (bottomBar != null) { + bottomBar() + } + } + } +} + +@Preview(name = "Wrapper", widthDp = (1080 / 3), heightDp = (1240 / 3)) +@Composable +fun Lp3KeyboardWrapperPreview() { + Lp3KeyboardTheme(DarkKeyboardColors) { + Column(verticalArrangement = Arrangement.Bottom, modifier = Modifier.fillMaxSize()) { + val keyboardOptions = KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = true + ) + val layoutOptions = LayoutOptions(displayCloseButton = true) + Lp3KeyboardWrapper( + EnQwerty.UpperCaseLayout, + keyboardOptions, + layoutOptions, + previewCallback, + null + ) + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt new file mode 100644 index 00000000..094716bc --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt @@ -0,0 +1,92 @@ +package com.thelightphone.lp3Keyboard.ui + +import android.content.Context +import android.graphics.fonts.SystemFonts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight + +/** + * Resolves the Akkurat font family at runtime โ€” the .ttf/.otf files are + * license-restricted, so we can't ship them in this library's resources. + * Lookup order: + * 1. System fonts on the host device (LP3 hardware ships with Akkurat). + * 2. A res/font copy in the consumer's app if they have one locally + * (resolved via getIdentifier so a missing copy is a runtime miss, + * not a compile error). + * 3. FontFamily.Default. + */ +fun lightFontFamily(context: Context): FontFamily { + systemAkkuratFonts()?.let { return it } + bundledAkkuratFonts(context)?.let { return it } + return FontFamily.Default +} + +private fun systemAkkuratFonts(): FontFamily? { + val fonts = SystemFonts.getAvailableFonts() + .filter { it.file?.name?.startsWith("Akkurat", ignoreCase = true) == true } + .mapNotNull { font -> + val file = font.file ?: return@mapNotNull null + val weight = FontWeight(font.style.weight) + val style = if (font.style.slant != 0) FontStyle.Italic else FontStyle.Normal + Font(file = file, weight = weight, style = style) + } + return if (fonts.isNotEmpty()) FontFamily(fonts) else null +} + +private fun bundledAkkuratFonts(context: Context): FontFamily? { + val res = context.resources + val pkg = context.packageName + fun fontId(name: String): Int = res.getIdentifier(name, "font", pkg) + + val fonts = buildList { + fontId("akkuratll_light").takeIf { it != 0 } + ?.let { add(Font(it, FontWeight.Light)) } + fontId("akkuratll_regular").takeIf { it != 0 } + ?.let { add(Font(it, FontWeight.Normal)) } + fontId("akkuratpro_bold").takeIf { it != 0 } + ?.let { add(Font(it, FontWeight.Bold)) } + } + return if (fonts.isNotEmpty()) FontFamily(fonts) else null +} + +@Immutable +data class Lp3KeyboardColors( + val background: Color, + val foreground: Color, +) + +val DarkKeyboardColors = Lp3KeyboardColors( + background = Color.Black, + foreground = Color.White, +) + +val LightKeyboardColors = Lp3KeyboardColors( + background = Color.White, + foreground = Color.Black, +) + +val LocalKeyboardColors = staticCompositionLocalOf { DarkKeyboardColors } + +/** + * Provided by [Lp3Keyboard] after one runtime lookup; key composables read + * from it instead of calling [lightFontFamily] themselves so the system-font + * scan only happens once per keyboard, not once per key. + */ +internal val LocalAkkuratFamily = staticCompositionLocalOf { FontFamily.Default } + +@Composable +fun Lp3KeyboardTheme( + colors: Lp3KeyboardColors = DarkKeyboardColors, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalKeyboardColors provides colors) { + content() + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt new file mode 100644 index 00000000..4b8fbb3f --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt @@ -0,0 +1,22 @@ +package com.thelightphone.lp3Keyboard.ui + +fun isEmojiCodePoint(cp: Int): Boolean { + // ZWJ and variation selector-16 are combiners, not standalone glyphs. + if (cp == 0x200D || cp == 0xFE0F) return false + return cp in 0x1F000..0x1FFFF || // Most modern emoji (supplementary plane) + cp in 0x2300..0x23FF || // Misc Technical (โŒš โŒ› โฐ โ€ฆ) + cp in 0x2600..0x27BF || // Misc Symbols, Dingbats (โ˜€ โœจ โค โ€ฆ) + cp in 0x2B00..0x2BFF // Misc Symbols & Arrows +} + +fun parseEmojiString(allEmojis: String?): List? { + if (allEmojis == null) return null + val codePoints = mutableListOf() + var i = 0 + while (i < allEmojis.length) { + val cp = allEmojis.codePointAt(i) + if (isEmojiCodePoint(cp)) codePoints.add(cp) + i += Character.charCount(cp) + } + return codePoints +} \ No newline at end of file diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt new file mode 100644 index 00000000..56975122 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt @@ -0,0 +1,125 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.ICON_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.IconKey +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardLayoutCapture +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.R +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +private val BeAzertySwipeConfig: SwipeConfig by lazy { + object : Lp3KeyboardLayoutCapture("abcdefghijklmnopqrstuvwxyz") { + override fun report(code: Int, bounds: Rect) { + val lower = if (code in 'A'.code..'Z'.code) code + 32 else code + if (lower !in 'a'.code..'z'.code) return + // onGloballyPositioned fires on every layout pass; skip identical + // writes so we don't churn the snapshot or re-fire boundsFlow. + if (letterBounds[lower] == bounds) return + letterBounds[lower] = bounds + } + } +} + + +/** The layouts for Belgian AZERTY. */ +object BeAzerty { + object LowerCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + + override val swipeConfig: SwipeConfig + get() = BeAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("azertyuiop", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("qsdfghjklm", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("wxcvbn", callback, swipeConfig, options) { + IconKey( + R.drawable.up_lp3, + SpecialKey.UpCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object CapsLockedLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = BeAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("AZERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("QSDFGHJKLM", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("WXCVBN", callback, swipeConfig, options) { + IconKey( + R.drawable.caps_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(9.dp).padding(bottom = 2.dp, end = 4.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object UpperCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = BeAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("AZERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("QSDFGHJKLM", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("WXCVBN", callback, swipeConfig, options) { + IconKey( + R.drawable.down_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt new file mode 100644 index 00000000..89f6b4db --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt @@ -0,0 +1,136 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.ICON_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.IconKey +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardLayoutCapture +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.R +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +private val EnColemakSwipeConfig: SwipeConfig by lazy { + object : Lp3KeyboardLayoutCapture("abcdefghijklmnopqrstuvwxyz") { + override fun report(code: Int, bounds: Rect) { + val lower = if (code in 'A'.code..'Z'.code) code + 32 else code + if (lower !in 'a'.code..'z'.code) return + // onGloballyPositioned fires on every layout pass; skip identical + // writes so we don't churn the snapshot or re-fire boundsFlow. + if (letterBounds[lower] == bounds) return + letterBounds[lower] = bounds + } + } +} + +/** + * The layouts for English Colemak. + * + * "Colemak is a modern alternative to the QWERTY and Dvorak layouts, designed for efficient and + * ergonomic touch typing in English." + * + * See https://colemak.com + * + * To keep the top row right-aligned to avoid a strangly skewed layout, the upper right key is + * filled with `'` lower/caps and `"` shifted. This is where `;` is on Colemak, but as that's a + * rarely used key, one of the most common symbols is used. For analysis and rational, see + * https://github.com/lightphone/light-keyboard/pull/4#pullrequestreview-4675526031 + */ +object EnColemak { + object LowerCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + + override val swipeConfig: SwipeConfig + get() = EnColemakSwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("qwfpgjluy'", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("arstdhneio", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("zxcvbkm", callback, swipeConfig, options) { + IconKey( + R.drawable.up_lp3, + SpecialKey.UpCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object CapsLockedLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = EnColemakSwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("QWFPGJLUY'", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("ARSTDHNEIO", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("ZXCVBKM", callback, swipeConfig, options) { + IconKey( + R.drawable.caps_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(9.dp).padding(bottom = 2.dp, end = 4.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object UpperCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = EnColemakSwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("QWFPGJLUY\"", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("ARSTDHNEIO", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("ZXCVBKM", callback, swipeConfig, options) { + IconKey( + R.drawable.down_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt new file mode 100644 index 00000000..fc22e7a8 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt @@ -0,0 +1,125 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.ICON_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.IconKey +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardLayoutCapture +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.R +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +private val EnQwertySwipeConfig: SwipeConfig by lazy { + object : Lp3KeyboardLayoutCapture("abcdefghijklmnopqrstuvwxyz") { + override fun report(code: Int, bounds: Rect) { + val lower = if (code in 'A'.code..'Z'.code) code + 32 else code + if (lower !in 'a'.code..'z'.code) return + // onGloballyPositioned fires on every layout pass; skip identical + // writes so we don't churn the snapshot or re-fire boundsFlow. + if (letterBounds[lower] == bounds) return + letterBounds[lower] = bounds + } + } +} + + +/** The layouts for English QWERTY. */ +object EnQwerty { + object LowerCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + + override val swipeConfig: SwipeConfig + get() = EnQwertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("qwertyuiop", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("asdfghjkl", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("zxcvbnm", callback, swipeConfig, options) { + IconKey( + R.drawable.up_lp3, + SpecialKey.UpCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object CapsLockedLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = EnQwertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("QWERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("ASDFGHJKL", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("ZXCVBNM", callback, swipeConfig, options) { + IconKey( + R.drawable.caps_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(9.dp).padding(bottom = 2.dp, end = 4.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object UpperCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = EnQwertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("QWERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("ASDFGHJKL", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("ZXCVBNM", callback, swipeConfig, options) { + IconKey( + R.drawable.down_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt new file mode 100644 index 00000000..a5a343de --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt @@ -0,0 +1,172 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.DefaultRow +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.Key +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.MEDIUM_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +/** Layouts and data generally shared across English keyboards. */ +object EnShared { + object NumberLayout : Layout { + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("1234567890", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("-/:;()$&@\"", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow(".,?!'", callback, swipeConfig, options) { + MultiLabelKey("#+=", SpecialKey.Symbols, callback, options.enableKeyAnimation) + } + FinalRow(options, callback) { + MultiLabelKey("ABC", SpecialKey.Letters, callback, options.enableKeyAnimation) + } + } + } + + object SymbolsLayout : Layout { + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("[]{}#%^*+=", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("_\\|~<>โ‚ฌยฃยฅ", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow(".,?!'", callback, swipeConfig, options) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + FinalRow(options, callback) { + MultiLabelKey("ABC", SpecialKey.Letters, callback, options.enableKeyAnimation) + } + } + } + + object EmojiLayout : Layout { + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + // current layout supports 3 rows of 8 + val emojiRows = options.emojis?.chunked(8)?.take(3) ?: return + for (row in emojiRows) { + DefaultRow { + for (emoji in row) { + Key( + emoji, + callback, + swipeConfig, + options.enableKeyAnimation, + width = MEDIUM_KEY_WIDTH_DP.dp + ) + } + } + } + } + } + + class ExtendedCharKeyboard(rootCode: Int) : Layout { + private val rows = extendedCharMapping[rootCode] + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + rows?.forEach { rowKeys -> + DefaultRow { + for (char in rowKeys) { + Key( + char.code, + callback, + swipeConfig, + options.enableKeyAnimation, + width = MEDIUM_KEY_WIDTH_DP.dp + ) + } + } + } + } + } + + val extendedCharMapping = mapOf( + 'A'.code to listOf( + listOf('ร€', 'ร', 'ร‚', 'ร„', 'ร†'), + listOf('รƒ', 'ร…', 'ฤ€', 'ฤ‚', 'ฤ„'), + ), + 'a'.code to listOf( + listOf('ร ', 'รก', 'รข', 'รค', 'รฆ'), + listOf('รฃ', 'รฅ', 'ฤ', 'ฤƒ', 'ฤ…'), + ), + 'C'.code to listOf( + listOf('ร‡', 'ฤ†', 'ฤŒ'), + ), + 'c'.code to listOf( + listOf('รง', 'ฤ‡', 'ฤ'), + ), + 'E'.code to listOf( + listOf('รˆ', 'ร‰', 'รŠ', 'ร‹', 'ฤ’', 'ฤ–', 'ฤ˜'), + ), + 'e'.code to listOf( + listOf('รจ', 'รฉ', 'รช', 'รซ', 'ฤ“', 'ฤ—', 'ฤ™'), + ), + 'I'.code to listOf( + listOf('รŽ', 'ร', 'ร', 'ฤช', 'ฤฎ', 'รŒ'), + ), + 'i'.code to listOf( + listOf('รฎ', 'รฏ', 'รญ', 'ฤซ', 'ฤฏ', 'รฌ'), + ), + 'L'.code to listOf( + listOf('ล'), + ), + 'l'.code to listOf( + listOf('ล‚'), + ), + 'N'.code to listOf( + listOf('ร‘', 'ลƒ'), + ), + 'n'.code to listOf( + listOf('รฑ', 'ล„'), + ), + 'O'.code to listOf( + listOf('ร”', 'ร–', 'ร’', 'ร“', 'ล’', 'ร˜', 'ลŒ', 'ร•'), + ), + 'o'.code to listOf( + listOf('รด', 'รถ', 'รฒ', 'รณ', 'ล“', 'รธ', 'ล', 'รต'), + ), + 'S'.code to listOf( + listOf('แบž', 'ลš', 'ล '), + ), + 's'.code to listOf( + listOf('รŸ', 'ล›', 'ลก'), + ), + 'U'.code to listOf( + listOf('ร›', 'รœ', 'ร™', 'รš', 'ลช'), + ), + 'u'.code to listOf( + listOf('รป', 'รผ', 'รน', 'รบ', 'ลซ'), + ), + 'Y'.code to listOf( + listOf('ลธ'), + ), + 'y'.code to listOf( + listOf('รฟ'), + ), + 'Z'.code to listOf( + listOf('ลฝ', 'ลน', 'ลป'), + ), + 'z'.code to listOf( + listOf('ลพ', 'ลบ', 'ลผ'), + ), + ) +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt new file mode 100644 index 00000000..5aa74a4b --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt @@ -0,0 +1,125 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.ICON_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.IconKey +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardLayoutCapture +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.R +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow + +private val FrAzertySwipeConfig: SwipeConfig by lazy { + object : Lp3KeyboardLayoutCapture("abcdefghijklmnopqrstuvwxyz") { + override fun report(code: Int, bounds: Rect) { + val lower = if (code in 'A'.code..'Z'.code) code + 32 else code + if (lower !in 'a'.code..'z'.code) return + // onGloballyPositioned fires on every layout pass; skip identical + // writes so we don't churn the snapshot or re-fire boundsFlow. + if (letterBounds[lower] == bounds) return + letterBounds[lower] = bounds + } + } +} + + +/** The layouts for French AZERTY. */ +object FrAzerty { + object LowerCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + + override val swipeConfig: SwipeConfig + get() = FrAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("azertyuiop", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("qsdfghjklm", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("wxcvbn", callback, swipeConfig, options) { + IconKey( + R.drawable.up_lp3, + SpecialKey.UpCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object CapsLockedLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = FrAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("AZERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("QSDFGHJKLM", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("WXCVBN", callback, swipeConfig, options) { + IconKey( + R.drawable.caps_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(9.dp).padding(bottom = 2.dp, end = 4.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } + + object UpperCaseLayout : Layout { + override val isRootLayout: Boolean + get() = true + override val swipeConfig: SwipeConfig + get() = FrAzertySwipeConfig + + @Composable + override fun ColumnScope.Render( + options: KeyboardOptions, + callback: Lp3KeyboardCallback + ) { + FirstRow("AZERTYUIOP", callback, swipeConfig, options.enableKeyAnimation) + SecondRow("QSDFGHJKLM", callback, swipeConfig, options.enableKeyAnimation) + ThirdRow("WXCVBN", callback, swipeConfig, options) { + IconKey( + R.drawable.down_lp3, + SpecialKey.DownCase, + callback, + options.enableKeyAnimation, + width = ICON_KEY_WIDTH_DP.dp, + modifier = Modifier.padding(12.dp).padding(bottom = 6.dp, end = 8.dp) + ) + } + FinalRow(options, callback) { + MultiLabelKey("123", SpecialKey.Numbers, callback, options.enableKeyAnimation) + } + } + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt new file mode 100644 index 00000000..64f84314 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt @@ -0,0 +1,107 @@ +package com.thelightphone.lp3Keyboard.ui.layout + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.thelightphone.lp3Keyboard.ui.DefaultRow +import com.thelightphone.lp3Keyboard.ui.FinalRow +import com.thelightphone.lp3Keyboard.ui.FirstRow +import com.thelightphone.lp3Keyboard.ui.ICON_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.IconKey +import com.thelightphone.lp3Keyboard.ui.Key +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.MEDIUM_KEY_WIDTH_DP +import com.thelightphone.lp3Keyboard.ui.MultiLabelKey +import com.thelightphone.lp3Keyboard.ui.R +import com.thelightphone.lp3Keyboard.ui.SecondRow +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.ThirdRow +import com.thelightphone.lp3Keyboard.ui.viewmodel.BeAzertyLp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.EnColemakLp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.EnQwertyLp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.FrAzertyLp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3RepeatableKeyboardCallback +import com.thelightphone.lp3Keyboard.ui.viewmodel.defaultEmojis +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.Locale + +enum class LayoutRegistryItem( + val locale: Locale, + val variant: String, + val label: String +) { + EnQwerty(Locale.ENGLISH, "qwerty", "QWERTY (English)"), + EnColemak(Locale.ENGLISH, "colemak", "Colemak (English)"), + FrAzerty(Locale.FRENCH, "azerty", "AZERTY (French)"), + BeAzerty(Locale("nl", "BE"), "azerty", "AZERTY (Belgium)") + ; + + val uniqueId: String = "${locale}_$variant" +} + +fun LayoutRegistryItem.buildRootViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + } +): Lp3KeyboardViewModel { + return when (this) { + LayoutRegistryItem.EnQwerty -> EnQwertyLp3KeyboardViewModel( + passedCallback, + swipeCallback, + haptic, + optionsForLayout + ) + + LayoutRegistryItem.EnColemak -> EnColemakLp3KeyboardViewModel( + passedCallback, + swipeCallback, + haptic, + optionsForLayout + ) + + LayoutRegistryItem.FrAzerty -> FrAzertyLp3KeyboardViewModel( + passedCallback, + swipeCallback, + haptic, + optionsForLayout + ) + + LayoutRegistryItem.BeAzerty -> BeAzertyLp3KeyboardViewModel( + passedCallback, + swipeCallback, + haptic, + optionsForLayout + ) + } +} + +interface SwipeConfig { + fun deriveLayout(): Triple? + fun report(code: Int, bounds: Rect) + fun letterBoundsRect(): Rect? + val boundsFlow: Flow +} + +sealed interface Layout { + @Composable + fun ColumnScope.Render(options: KeyboardOptions, callback: Lp3KeyboardCallback) + val isRootLayout: Boolean + get() = false + + val swipeConfig: SwipeConfig? + get() = null +} \ No newline at end of file diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt new file mode 100644 index 00000000..8a616e16 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt @@ -0,0 +1,39 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.layout.BeAzerty +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class BeAzertyLp3KeyboardViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback? = null, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ) +) : EnBaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = BeAzerty.LowerCaseLayout, + lowerCaseLayout = BeAzerty.LowerCaseLayout, + upperCaseLayout = BeAzerty.UpperCaseLayout, + capsLockedLayout = BeAzerty.CapsLockedLayout, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt new file mode 100644 index 00000000..1ab93fd1 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt @@ -0,0 +1,272 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.SpecialKey.Close +import com.thelightphone.lp3Keyboard.ui.layout.EnShared +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * An abstract view model for the base, shared logic for English keyboards. + * + * Typically, setting initial, lower, upper, and capslock layouts is enough to define a standard + * English keyboard. + */ +abstract class EnBaseViewModel( + private val passedCallback: Lp3RepeatableKeyboardCallback, + private val swipeCallback: Lp3KeyboardSwipeCallback?, + private val haptic: () -> Unit = {}, + private val optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + override val keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ), + val initialLayout: Layout, + val lowerCaseLayout: Layout, + val upperCaseLayout: Layout, + val capsLockedLayout: Layout, +) : ViewModel(), Lp3KeyboardViewModel { + + var previousLayout: Layout? = null + private set + + private var swipeActive = false + + private val delegateCallback: Lp3RepeatableKeyboardCallback? + get() = passedCallback.takeUnless { swipeActive } + + override val layoutFlow: MutableStateFlow = MutableStateFlow(initialLayout) + + private fun setLayout(layout: Layout) { + previousLayout = layoutFlow.value + layoutOptionsFlow.value = optionsForLayout(layout) + layoutFlow.value = layout + } + + override val layoutOptionsFlow = MutableStateFlow(optionsForLayout(initialLayout)) + + companion object { + private const val REPEAT_INTERVAL_MS = 350L + } + + private val heldSpecialKeys = mutableMapOf() + private val heldKeys = mutableMapOf() + + override fun cancelHeldKeys() { + heldSpecialKeys.values.forEach { it.cancel() } + heldSpecialKeys.clear() + heldKeys.values.forEach { it.cancel() } + heldKeys.clear() + } + + var capsMode: CapsMode = CapsMode.Off + private set + + private fun showAlphabetLayout() { + setLayout( + when (capsMode) { + CapsMode.Off -> lowerCaseLayout + CapsMode.Single -> upperCaseLayout + CapsMode.Locked -> capsLockedLayout + } + ) + } + + override fun onKeyPressed(code: Int) { + haptic() + delegateCallback?.onKeyPressed(code) + } + + override fun onSpecialKeyPressed(key: SpecialKey) { + haptic() + delegateCallback?.onSpecialKeyPressed(key) + } + + override fun onKeyReleased(code: Int) { + heldKeys.remove(code)?.apply { + cancel() + return // swallow on key released if held + } + // eagerly drop single-caps so fast typists see lowercase before the IME round-trip + if (capsMode == CapsMode.Single) { + capsMode = CapsMode.Off + showAlphabetLayout() + } + // auto-dismiss when a special key is typed + if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { + setLayout(previousLayout ?: lowerCaseLayout) + } + delegateCallback?.onKeyReleased(code) + } + + override fun onKeyCancelled(code: Int) { + // Finger left the key bounds โ€” treat as the start of a swipe (or a + // deliberate tap-cancel). Clean up press state but don't fire the IME + // release, which is where text actually gets committed. + heldKeys.remove(code)?.cancel() + if (layoutFlow.value is EnShared.ExtendedCharKeyboard) { + setLayout(previousLayout ?: lowerCaseLayout) + } + } + + override fun onSpecialKeyReleased(key: SpecialKey) { + val repeatJob = heldSpecialKeys.remove(key) + // if we were long-pressing, swallow the release + repeatJob?.apply { + cancel() + return + } + var consumed = true + when (key) { + SpecialKey.UpCase, SpecialKey.DownCase -> { + capsMode = when (capsMode) { + CapsMode.Off -> CapsMode.Single + CapsMode.Single, CapsMode.Locked -> CapsMode.Off + } + showAlphabetLayout() + } + + SpecialKey.Numbers -> { + setLayout(EnShared.NumberLayout) + } + + SpecialKey.Letters -> { + showAlphabetLayout() + } + + SpecialKey.Symbols -> { + setLayout(EnShared.SymbolsLayout) + } + + SpecialKey.Emojis -> { + setLayout(EnShared.EmojiLayout) + } + + Close -> { + if (!layoutFlow.value.isRootLayout) { + showAlphabetLayout() + } else { + consumed = false + } + } + + else -> { + consumed = false + } + } + if (!consumed) { + delegateCallback?.onSpecialKeyReleased(key) + } + } + + /** Called by IME after each character to handle system-requested caps. */ + override fun setCapsMode(enabled: Boolean) { + if (capsMode == CapsMode.Locked) return + capsMode = if (enabled) CapsMode.Single else CapsMode.Off + when (layoutFlow.value) { + // only update the layout if we were already showing letters + lowerCaseLayout, upperCaseLayout, capsLockedLayout -> showAlphabetLayout() + else -> {} + } + } + + override fun onKeyLongPressed(code: Int) { + heldKeys[code]?.cancel() + if (EnShared.extendedCharMapping.containsKey(code)) { + haptic() + setLayout(EnShared.ExtendedCharKeyboard(code)) + heldKeys[code] = viewModelScope.launch { } + return + } + delegateCallback?.onKeyLongPressed(code) + heldKeys[code] = viewModelScope.launch { + while (isActive) { + delay(REPEAT_INTERVAL_MS) + delegateCallback?.onKeyRepeated(code) + } + } + } + + override fun onSpecialKeyLongPressed(key: SpecialKey) { + heldSpecialKeys[key]?.cancel() + val allowRepeats = when (key) { + SpecialKey.UpCase, SpecialKey.DownCase -> { + capsMode = if (capsMode == CapsMode.Locked) CapsMode.Off else CapsMode.Locked + heldSpecialKeys[key] = viewModelScope.launch { } + showAlphabetLayout() + // don't allow repeats since we switched layouts and the original button is gone + false + } + + else -> true + } + haptic() + delegateCallback?.onSpecialKeyLongPressed(key) + if (allowRepeats) { + heldSpecialKeys[key] = viewModelScope.launch { + while (isActive) { + delay(REPEAT_INTERVAL_MS) + delegateCallback?.onSpecialKeyRepeated(key) + } + } + } + } + + override fun onSubmitWord(word: CharSequence) { + delegateCallback?.onSubmitWord("$word ") + } + + override fun onSwipeStarted() { + if (keyboardOptionsFlow.value.swipeEnabled) { + swipeActive = true + } + } + + override fun onSwipeLayoutReady( + letters: String, + cx: FloatArray, + cy: FloatArray + ) { + swipeCallback?.onSwipeLayoutReady(letters, cx, cy) + } + + override fun onSwipeCompleted( + x: FloatArray, + y: FloatArray, + t: FloatArray + ): List { + val results = swipeCallback?.onSwipeCompleted(x,y,t) ?: emptyList() + swipeActive = false + if (results.isNotEmpty()) { + swipeCallback?.getWordForResult(results[0]) + ?.let(this::onSubmitWord) + } + return results + } + + override fun getWordForResult(swipeResult: SwipeResult) = swipeCallback?.getWordForResult(swipeResult) + + override fun onCleared() { + super.onCleared() + cancelHeldKeys() + } +} diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt new file mode 100644 index 00000000..9d2ec6ba --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt @@ -0,0 +1,39 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.layout.EnColemak +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class EnColemakLp3KeyboardViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback? = null, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ) +) : EnBaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = EnColemak.LowerCaseLayout, + lowerCaseLayout = EnColemak.LowerCaseLayout, + upperCaseLayout = EnColemak.UpperCaseLayout, + capsLockedLayout = EnColemak.CapsLockedLayout, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt new file mode 100644 index 00000000..b55b9c2b --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt @@ -0,0 +1,39 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class EnQwertyLp3KeyboardViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback? = null, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ) +) : EnBaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = EnQwerty.LowerCaseLayout, + lowerCaseLayout = EnQwerty.LowerCaseLayout, + upperCaseLayout = EnQwerty.UpperCaseLayout, + capsLockedLayout = EnQwerty.CapsLockedLayout, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt new file mode 100644 index 00000000..333678b5 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt @@ -0,0 +1,39 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.layout.FrAzerty +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FrAzertyLp3KeyboardViewModel( + passedCallback: Lp3RepeatableKeyboardCallback, + swipeCallback: Lp3KeyboardSwipeCallback? = null, + haptic: () -> Unit = {}, + optionsForLayout: (Layout) -> LayoutOptions = { + LayoutOptions( + displayCloseButton = true + ) + }, + keyboardOptionsFlow: StateFlow = MutableStateFlow( + KeyboardOptions( + defaultEmojis, + displayReturn = true, + displayVoice = true, + enableKeyAnimation = true, + swipeEnabled = false + ) + ) +) : EnBaseViewModel( + passedCallback = passedCallback, + swipeCallback = swipeCallback, + haptic = haptic, + optionsForLayout = optionsForLayout, + keyboardOptionsFlow = keyboardOptionsFlow, + initialLayout = FrAzerty.LowerCaseLayout, + lowerCaseLayout = FrAzerty.LowerCaseLayout, + upperCaseLayout = FrAzerty.UpperCaseLayout, + capsLockedLayout = FrAzerty.CapsLockedLayout, +) diff --git a/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt new file mode 100644 index 00000000..7f09d186 --- /dev/null +++ b/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt @@ -0,0 +1,53 @@ +package com.thelightphone.lp3Keyboard.ui.viewmodel + +import com.thelightphone.lp3Keyboard.ui.KeyboardOptions +import com.thelightphone.lp3Keyboard.ui.LayoutOptions +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardCallback +import com.thelightphone.lp3Keyboard.ui.Lp3KeyboardSwipeCallback +import com.thelightphone.lp3Keyboard.ui.SpecialKey +import com.thelightphone.lp3Keyboard.ui.layout.Layout +import kotlinx.coroutines.flow.StateFlow + +interface Lp3KeyboardViewModel : Lp3KeyboardCallback, Lp3KeyboardSwipeCallback { + val layoutFlow: StateFlow + val keyboardOptionsFlow: StateFlow + val layoutOptionsFlow: StateFlow + fun cancelHeldKeys() + + /** Called by the IME after each character to handle system-requested caps. */ + fun setCapsMode(enabled: Boolean) +} + +val defaultEmojis = listOf( + "๐Ÿ˜…", + "โ˜บ๏ธ", + "๐Ÿ™ƒ", + "๐Ÿ˜", + "๐Ÿ˜œ", + "๐Ÿ˜‚", + "๐Ÿ˜ญ", + "๐Ÿ˜Ž", + "๐Ÿ™Œ", + "๐Ÿ‘", + "๐Ÿ‘Ž", + "๐Ÿคž", + "โœŒ๏ธ", + "๐Ÿ‘Œ", + "๐Ÿ‘‹", + "๐Ÿ™", + "โœจ", + "๐Ÿ”ฅ", + "โค๏ธ", + "๐Ÿ’”", + "๐Ÿ†", + "๐ŸŽฏ", + "๐Ÿ‘‘", + "๐Ÿ‘€" +).map { it.codePointAt(0) } + +enum class CapsMode { Off, Single, Locked } + +interface Lp3RepeatableKeyboardCallback : Lp3KeyboardCallback { + fun onKeyRepeated(code: Int) + fun onSpecialKeyRepeated(specialKey: SpecialKey) +} diff --git a/ui/src/main/res/drawable/back_lp3.xml b/ui/src/main/res/drawable/back_lp3.xml new file mode 100644 index 00000000..60680821 --- /dev/null +++ b/ui/src/main/res/drawable/back_lp3.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/main/res/drawable/caps_lp3.xml b/ui/src/main/res/drawable/caps_lp3.xml new file mode 100644 index 00000000..5e3577e3 --- /dev/null +++ b/ui/src/main/res/drawable/caps_lp3.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui/src/main/res/drawable/down_lp3.xml b/ui/src/main/res/drawable/down_lp3.xml new file mode 100644 index 00000000..38d3dd50 --- /dev/null +++ b/ui/src/main/res/drawable/down_lp3.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/main/res/drawable/microphone_lp3.xml b/ui/src/main/res/drawable/microphone_lp3.xml new file mode 100644 index 00000000..588ad75b --- /dev/null +++ b/ui/src/main/res/drawable/microphone_lp3.xml @@ -0,0 +1,14 @@ + + + + diff --git a/ui/src/main/res/drawable/return_lp3.xml b/ui/src/main/res/drawable/return_lp3.xml new file mode 100644 index 00000000..025ab740 --- /dev/null +++ b/ui/src/main/res/drawable/return_lp3.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/main/res/drawable/smile.xml b/ui/src/main/res/drawable/smile.xml new file mode 100644 index 00000000..fb6498f9 --- /dev/null +++ b/ui/src/main/res/drawable/smile.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/ui/src/main/res/drawable/up_lp3.xml b/ui/src/main/res/drawable/up_lp3.xml new file mode 100644 index 00000000..13e49802 --- /dev/null +++ b/ui/src/main/res/drawable/up_lp3.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt b/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt new file mode 100644 index 00000000..d177876f --- /dev/null +++ b/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt @@ -0,0 +1,55 @@ +package com.thelightphone.lp3Keyboard.ui + +import com.thelightphone.lp3Keyboard.ui.layout.EnQwerty +import com.thelightphone.lp3Keyboard.ui.viewmodel.CapsMode +import com.thelightphone.lp3Keyboard.ui.viewmodel.EnQwertyLp3KeyboardViewModel +import com.thelightphone.lp3Keyboard.ui.viewmodel.Lp3RepeatableKeyboardCallback +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class EnQwertyViewModelTest { + + private val callback = mockk(relaxed = true) + private val swipeCallback = mockk>(relaxed = true) + + private val vm = EnQwertyLp3KeyboardViewModel( + passedCallback = callback, + swipeCallback = swipeCallback, + ) + + private fun tapShift() = vm.apply{ + onSpecialKeyPressed(SpecialKey.UpCase) + onSpecialKeyReleased(SpecialKey.UpCase) + } + + @Test + fun `onKeyPressed does not swap layout mid-gesture in one-shot caps`() { + tapShift() + assertEquals(CapsMode.Single, vm.capsMode) + assertSame(EnQwerty.UpperCaseLayout, vm.layoutFlow.value) + + vm.onKeyPressed('Q'.code) + assertSame( + "onKeyPressed must not swap layoutFlow while a key is held down", + EnQwerty.UpperCaseLayout, + vm.layoutFlow.value + ) + } + + @Test + fun `single-shift then letter commits the capital and reverts to lowercase`() { + tapShift() + + // Full press -> release gesture on the capital key. + vm.onKeyPressed('Q'.code) + vm.onKeyReleased('Q'.code) + + // The release is what commits the character downstream in the IME. + verify(exactly = 1) { callback.onKeyReleased('Q'.code) } + assertEquals(CapsMode.Off, vm.capsMode) + assertSame(EnQwerty.LowerCaseLayout, vm.layoutFlow.value) + } +} diff --git a/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt b/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt new file mode 100644 index 00000000..9a9eda1a --- /dev/null +++ b/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.thelightphone.lp3Keyboard.ui + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file From bade6bd94c5536cf29fad54866059d5c3b4a3da9 Mon Sep 17 00:00:00 2001 From: jbriones95 <108902016+jbriones95@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:13:44 -0600 Subject: [PATCH 5/5] Use local Light Keyboard UI dependency --- gradle/libs.versions.toml | 1 - sdk/ui/build.gradle.kts | 2 +- settings.gradle.kts | 20 +--- .../light-keyboard/ui/build.gradle.kts | 92 ++++--------------- .../transit/RouteSelectionScreen.kt | 52 ++++++++++- 5 files changed, 69 insertions(+), 98 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cb1c00d..78ae3488 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,6 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -light-keyboard = { module = "com.thelightphone.lp3keyboard:ui", version = "0.0.16"} androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } diff --git a/sdk/ui/build.gradle.kts b/sdk/ui/build.gradle.kts index 189dfa6e..a8250809 100644 --- a/sdk/ui/build.gradle.kts +++ b/sdk/ui/build.gradle.kts @@ -43,7 +43,7 @@ afterEvaluate { } dependencies { - api(libs.light.keyboard) + api(project(":light-keyboard-ui")) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/settings.gradle.kts b/settings.gradle.kts index c23a0572..a4c756ae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,3 @@ -import java.util.Properties - pluginManagement { repositories { google() @@ -8,27 +6,11 @@ pluginManagement { } } -val localProperties = Properties() -val localPropertiesFile = file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.inputStream().use { localProperties.load(it) } -} -val ghUsername = localProperties.getProperty("gpr.user") ?: System.getenv("GH_PACKAGES_USER") -val ghPassword = localProperties.getProperty("gpr.key") ?: System.getenv("GH_PACKAGES_TOKEN") - dependencyResolutionManagement { repositories { mavenLocal() google() mavenCentral() - maven { - name = "GitHubPackages-Keyboard" - url = uri("https://maven.pkg.github.com/lightphone/light-keyboard") - credentials { - username = ghUsername - password = ghPassword - } - } } } @@ -38,6 +20,8 @@ includeBuild("plugin") include(":lint-rules") include(":sdk:shared") include(":sdk:ui") +include(":light-keyboard-ui") +project(":light-keyboard-ui").projectDir = file("third_party/light-keyboard/ui") include(":sdk:client") include(":sdk:server") include(":sdk:emulator") diff --git a/third_party/light-keyboard/ui/build.gradle.kts b/third_party/light-keyboard/ui/build.gradle.kts index 9e254ec3..77e024f5 100644 --- a/third_party/light-keyboard/ui/build.gradle.kts +++ b/third_party/light-keyboard/ui/build.gradle.kts @@ -1,97 +1,39 @@ -import java.util.Properties - plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - `maven-publish` } -// for publishing -val uiVersion = providers.gradleProperty("projectVersion").get() - android { namespace = "com.thelightphone.lp3Keyboard.ui" - compileSdk = 34 + compileSdk = rootProject.ext["compileSdk"] as Int defaultConfig { - minSdk = 33 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + minSdk = rootProject.ext["minSdk"] as Int consumerProguardFiles("consumer-rules.pro") } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } -} - -android.publishing { - singleVariant("release") { - withSourcesJar() + sourceCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String) + targetCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String) } } -val localProperties = Properties().apply { - rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) } -} - -publishing { - // TODO signing config - publications { - register("release") { - groupId = "com.thelightphone.lp3keyboard" - artifactId = "ui" - version = uiVersion - - afterEvaluate { - from(components["release"]) - } - } - } - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/lightphone/light-keyboard") - credentials { - username = localProperties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") - password = localProperties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") - } - } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(rootProject.ext["jvmTarget"] as String)) } } dependencies { - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.androidx.compose.foundation) - api(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material) - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.lifecycle.viewmodel) - testImplementation(libs.junit) - testImplementation(libs.mockk) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) -} - -// Suppress Gradle module metadata so consumers use the POM exclusively. -tasks.withType { - enabled = false + implementation(platform(libs.compose.bom)) + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation(libs.compose.foundation) + api(libs.compose.ui) + implementation(libs.compose.material) + implementation(libs.compose.ui.tooling) + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.7") } diff --git a/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt index 9c3d8359..c05117fa 100644 --- a/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt +++ b/tool/src/main/kotlin/com/thelightphone/transit/RouteSelectionScreen.kt @@ -8,9 +8,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewModelScope @@ -21,9 +25,12 @@ import com.thelightphone.sdk.LightScreen import com.thelightphone.sdk.LightViewModel import com.thelightphone.sdk.SealedLightActivity import com.thelightphone.sdk.SimpleLightScreen +import com.thelightphone.sdk.rememberKeyboardOptions import com.thelightphone.sdk.ui.LightBarButton import com.thelightphone.sdk.ui.LightIcons import com.thelightphone.sdk.ui.LightText +import com.thelightphone.sdk.ui.LightTextField +import com.thelightphone.sdk.ui.LightTextInputEditor import com.thelightphone.sdk.ui.LightTextVariant import com.thelightphone.sdk.ui.LightTheme import com.thelightphone.sdk.ui.LightThemeController @@ -83,8 +90,27 @@ class RouteSelectionScreen( override fun Content() { val state by viewModel.state.collectAsState() val themeColors by LightThemeController.colors.collectAsState() + val keyboardOptionsFlow = rememberKeyboardOptions() + var searchEditorOpen by remember { mutableStateOf(false) } + var routeQuery by remember { mutableStateOf("") } + val searchTextState = rememberTextFieldState(routeQuery) - LightTheme(colors = themeColors) { + if (searchEditorOpen) { + LightTheme(colors = themeColors) { + LightTextInputEditor( + title = "Search Routes", + state = searchTextState, + onSubmit = { + routeQuery = it.toString().trim() + searchEditorOpen = false + }, + onBack = { searchEditorOpen = false }, + keyboardOptionsFlow = keyboardOptionsFlow, + submitIcon = LightIcons.SEARCH, + singleLine = true, + ) + } + } else LightTheme(colors = themeColors) { Column( modifier = Modifier .fillMaxSize() @@ -125,8 +151,27 @@ class RouteSelectionScreen( lighten = true, ) } else { - LazyColumn(modifier = Modifier.weight(1f)) { - items(s.routes) { route -> + val filteredRoutes = s.routes.filter { route -> + routeQuery.isBlank() || + route.routeId.contains(routeQuery, ignoreCase = true) || + route.displayName.contains(routeQuery, ignoreCase = true) + } + LightTextField( + label = "Search routes", + value = routeQuery, + placeholder = "All routes", + onClick = { searchEditorOpen = true }, + modifier = Modifier.padding(bottom = 20.dp), + ) + if (filteredRoutes.isEmpty()) { + LightText( + text = "No matching routes.", + variant = LightTextVariant.Copy, + lighten = true, + ) + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(filteredRoutes, key = { it.routeId }) { route -> LightText( text = route.displayName, variant = LightTextVariant.Copy, @@ -144,6 +189,7 @@ class RouteSelectionScreen( } .padding(vertical = 12.dp), ) + } } } }