diff --git a/README.md b/README.md
index 90ba42b0..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), 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). 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/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/netconfig/build.gradle.kts b/netconfig/build.gradle.kts
index bc134fee..f0078fe8 100644
--- a/netconfig/build.gradle.kts
+++ b/netconfig/build.gradle.kts
@@ -3,28 +3,7 @@
// 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.
+// cleartext traffic by default.
plugins {
alias(libs.plugins.android.library)
}
diff --git a/netconfig/src/main/AndroidManifest.xml b/netconfig/src/main/AndroidManifest.xml
index 39835cf5..669f8ac3 100644
--- a/netconfig/src/main/AndroidManifest.xml
+++ b/netconfig/src/main/AndroidManifest.xml
@@ -1,9 +1,5 @@
-
+
diff --git a/netconfig/src/main/res/xml/network_security_config.xml b/netconfig/src/main/res/xml/network_security_config.xml
index c6774ceb..da9d4398 100644
--- a/netconfig/src/main/res/xml/network_security_config.xml
+++ b/netconfig/src/main/res/xml/network_security_config.xml
@@ -1,9 +1,5 @@
-
+
realtime.ripta.com
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 694db05e..a4c756ae 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,5 +1,3 @@
-import java.util.Properties
-
pluginManagement {
repositories {
google()
@@ -8,26 +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
- }
- }
}
}
@@ -37,11 +20,12 @@ 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")
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")
diff --git a/third_party/light-keyboard/.github/workflows/pr-check.yml b/third_party/light-keyboard/.github/workflows/pr-check.yml
new file mode 100644
index 00000000..48791c71
--- /dev/null
+++ b/third_party/light-keyboard/.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/third_party/light-keyboard/.gitignore b/third_party/light-keyboard/.gitignore
new file mode 100644
index 00000000..3be3c279
--- /dev/null
+++ b/third_party/light-keyboard/.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/third_party/light-keyboard/.idea/.gitignore b/third_party/light-keyboard/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/third_party/light-keyboard/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/third_party/light-keyboard/.idea/AndroidProjectSystem.xml b/third_party/light-keyboard/.idea/AndroidProjectSystem.xml
new file mode 100644
index 00000000..4a53bee8
--- /dev/null
+++ b/third_party/light-keyboard/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/compiler.xml b/third_party/light-keyboard/.idea/compiler.xml
new file mode 100644
index 00000000..b86273d9
--- /dev/null
+++ b/third_party/light-keyboard/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/deploymentTargetSelector.xml b/third_party/light-keyboard/.idea/deploymentTargetSelector.xml
new file mode 100644
index 00000000..ca16a995
--- /dev/null
+++ b/third_party/light-keyboard/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/deviceManager.xml b/third_party/light-keyboard/.idea/deviceManager.xml
new file mode 100644
index 00000000..91f95584
--- /dev/null
+++ b/third_party/light-keyboard/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/gradle.xml b/third_party/light-keyboard/.idea/gradle.xml
new file mode 100644
index 00000000..6f6457b4
--- /dev/null
+++ b/third_party/light-keyboard/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/inspectionProfiles/Project_Default.xml b/third_party/light-keyboard/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..7061a0d6
--- /dev/null
+++ b/third_party/light-keyboard/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/migrations.xml b/third_party/light-keyboard/.idea/migrations.xml
new file mode 100644
index 00000000..f8051a6f
--- /dev/null
+++ b/third_party/light-keyboard/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/misc.xml b/third_party/light-keyboard/.idea/misc.xml
new file mode 100644
index 00000000..b2c751a3
--- /dev/null
+++ b/third_party/light-keyboard/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/.idea/runConfigurations.xml b/third_party/light-keyboard/.idea/runConfigurations.xml
new file mode 100644
index 00000000..16660f1d
--- /dev/null
+++ b/third_party/light-keyboard/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/CODE_OF_CONDUCT.md b/third_party/light-keyboard/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..ecb209e0
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/CONTRIBUTING.md b/third_party/light-keyboard/CONTRIBUTING.md
new file mode 100644
index 00000000..2121f068
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/LICENSE b/third_party/light-keyboard/LICENSE
new file mode 100644
index 00000000..7b3eef3f
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/README.md b/third_party/light-keyboard/README.md
new file mode 100644
index 00000000..d6f58279
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/.gitignore b/third_party/light-keyboard/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/third_party/light-keyboard/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/build.gradle.kts b/third_party/light-keyboard/app/build.gradle.kts
new file mode 100644
index 00000000..d2afd98c
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/proguard-rules.pro b/third_party/light-keyboard/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt b/third_party/light-keyboard/app/src/androidTest/java/com/thelightphone/lp3keyboard/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..89cc09ff
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/AndroidManifest.xml b/third_party/light-keyboard/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..05ba795f
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt b/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/IMEService.kt
new file mode 100644
index 00000000..7d04f394
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt b/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/LayoutPreferences.kt
new file mode 100644
index 00000000..ca1da7ae
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt b/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/LifecycleInputMethodService.kt
new file mode 100644
index 00000000..55ab4c4f
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt b/third_party/light-keyboard/app/src/main/java/com/thelightphone/lp3keyboard/MainActivity.kt
new file mode 100644
index 00000000..704a5ed9
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_background.xml b/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_foreground.xml b/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/third_party/light-keyboard/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/third_party/light-keyboard/app/src/main/res/values-night/themes.xml b/third_party/light-keyboard/app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..57e36378
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/values/colors.xml b/third_party/light-keyboard/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/app/src/main/res/values/strings.xml b/third_party/light-keyboard/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..01b761b0
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Lp3Keyboard
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/values/themes.xml b/third_party/light-keyboard/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..a85b6036
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/xml/backup_rules.xml b/third_party/light-keyboard/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..4df92558
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/xml/data_extraction_rules.xml b/third_party/light-keyboard/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/main/res/xml/method.xml b/third_party/light-keyboard/app/src/main/res/xml/method.xml
new file mode 100644
index 00000000..f181e9d5
--- /dev/null
+++ b/third_party/light-keyboard/app/src/main/res/xml/method.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt b/third_party/light-keyboard/app/src/test/java/com/thelightphone/lp3keyboard/ExampleUnitTest.kt
new file mode 100644
index 00000000..678f0e20
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/build.gradle.kts b/third_party/light-keyboard/build.gradle.kts
new file mode 100644
index 00000000..41b070ae
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradle.properties b/third_party/light-keyboard/gradle.properties
new file mode 100644
index 00000000..2a7ee45a
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradle/gradle-daemon-jvm.properties b/third_party/light-keyboard/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..6c1139ec
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradle/libs.versions.toml b/third_party/light-keyboard/gradle/libs.versions.toml
new file mode 100644
index 00000000..d059ce52
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradle/wrapper/gradle-wrapper.jar b/third_party/light-keyboard/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..8bdaf60c
Binary files /dev/null and b/third_party/light-keyboard/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/third_party/light-keyboard/gradle/wrapper/gradle-wrapper.properties b/third_party/light-keyboard/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..3fc8929a
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradlew b/third_party/light-keyboard/gradlew
new file mode 100755
index 00000000..ef07e016
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/gradlew.bat b/third_party/light-keyboard/gradlew.bat
new file mode 100644
index 00000000..5eed7ee8
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/jitpack.yml b/third_party/light-keyboard/jitpack.yml
new file mode 100644
index 00000000..1e41e00b
--- /dev/null
+++ b/third_party/light-keyboard/jitpack.yml
@@ -0,0 +1,2 @@
+jdk:
+ - openjdk17
\ No newline at end of file
diff --git a/third_party/light-keyboard/settings.gradle.kts b/third_party/light-keyboard/settings.gradle.kts
new file mode 100644
index 00000000..1fd07e36
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/.gitignore b/third_party/light-keyboard/ui/.gitignore
new file mode 100644
index 00000000..9156f19a
--- /dev/null
+++ b/third_party/light-keyboard/ui/.gitignore
@@ -0,0 +1,2 @@
+/build
+/src/main/res/font
\ No newline at end of file
diff --git a/third_party/light-keyboard/ui/build.gradle.kts b/third_party/light-keyboard/ui/build.gradle.kts
new file mode 100644
index 00000000..77e024f5
--- /dev/null
+++ b/third_party/light-keyboard/ui/build.gradle.kts
@@ -0,0 +1,39 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.thelightphone.lp3Keyboard.ui"
+ compileSdk = rootProject.ext["compileSdk"] as Int
+
+ defaultConfig {
+ minSdk = rootProject.ext["minSdk"] as Int
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
+ targetCompatibility = JavaVersion.toVersion(rootProject.ext["jvmTarget"] as String)
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(rootProject.ext["jvmTarget"] as String))
+ }
+}
+
+dependencies {
+ 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/third_party/light-keyboard/ui/consumer-rules.pro b/third_party/light-keyboard/ui/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/third_party/light-keyboard/ui/proguard-rules.pro b/third_party/light-keyboard/ui/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt b/third_party/light-keyboard/ui/src/androidTest/java/com/thelightphone/lp3Keyboard/ui/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..9abcacca
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/AndroidManifest.xml b/third_party/light-keyboard/ui/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..5dd7fb61
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/HardwareKeyboardInput.kt
new file mode 100644
index 00000000..5eee4f7b
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3Keyboard.kt
new file mode 100644
index 00000000..b3b5ee4c
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardLayoutCapture.kt
new file mode 100644
index 00000000..27fb537b
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardView.kt
new file mode 100644
index 00000000..72a7ba92
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Lp3KeyboardWrapper.kt
new file mode 100644
index 00000000..f77f1e56
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Style.kt
new file mode 100644
index 00000000..094716bc
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/Utils.kt
new file mode 100644
index 00000000..4b8fbb3f
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/BeAzerty.kt
new file mode 100644
index 00000000..56975122
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnColemak.kt
new file mode 100644
index 00000000..89f6b4db
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnQwerty.kt
new file mode 100644
index 00000000..fc22e7a8
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/EnShared.kt
new file mode 100644
index 00000000..a5a343de
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/FrAzerty.kt
new file mode 100644
index 00000000..5aa74a4b
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/layout/Lp3KeyboardLayouts.kt
new file mode 100644
index 00000000..64f84314
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/BeAzertyViewModel.kt
new file mode 100644
index 00000000..8a616e16
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnBaseViewModel.kt
new file mode 100644
index 00000000..1ab93fd1
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnColemakViewModel.kt
new file mode 100644
index 00000000..9d2ec6ba
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/EnQwertyViewModel.kt
new file mode 100644
index 00000000..b55b9c2b
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/FrAzertyViewModel.kt
new file mode 100644
index 00000000..333678b5
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt b/third_party/light-keyboard/ui/src/main/java/com/thelightphone/lp3Keyboard/ui/viewmodel/Lp3KeyboardViewModel.kt
new file mode 100644
index 00000000..7f09d186
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/main/res/drawable/back_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/back_lp3.xml
new file mode 100644
index 00000000..60680821
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/back_lp3.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/caps_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/caps_lp3.xml
new file mode 100644
index 00000000..5e3577e3
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/caps_lp3.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/down_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/down_lp3.xml
new file mode 100644
index 00000000..38d3dd50
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/down_lp3.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/microphone_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/microphone_lp3.xml
new file mode 100644
index 00000000..588ad75b
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/microphone_lp3.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/return_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/return_lp3.xml
new file mode 100644
index 00000000..025ab740
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/return_lp3.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/smile.xml b/third_party/light-keyboard/ui/src/main/res/drawable/smile.xml
new file mode 100644
index 00000000..fb6498f9
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/smile.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/third_party/light-keyboard/ui/src/main/res/drawable/up_lp3.xml b/third_party/light-keyboard/ui/src/main/res/drawable/up_lp3.xml
new file mode 100644
index 00000000..13e49802
--- /dev/null
+++ b/third_party/light-keyboard/ui/src/main/res/drawable/up_lp3.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/third_party/light-keyboard/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt b/third_party/light-keyboard/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/EnQwertyViewModelTest.kt
new file mode 100644
index 00000000..d177876f
--- /dev/null
+++ b/third_party/light-keyboard/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/third_party/light-keyboard/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt b/third_party/light-keyboard/ui/src/test/java/com/thelightphone/lp3Keyboard/ui/ExampleUnitTest.kt
new file mode 100644
index 00000000..9a9eda1a
--- /dev/null
+++ b/third_party/light-keyboard/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
diff --git a/tool/README.md b/tool/README.md
index 3bcdf209..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), 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). 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 15f3a7cf..60f84040 100644
--- a/tool/build.gradle.kts
+++ b/tool/build.gradle.kts
@@ -59,9 +59,7 @@ 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).
+ // 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 —
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/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),
)
+ }
}
}
}
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..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,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. Its two URLs below are
+ * reachable through the narrowly scoped cleartext exception provided by the :netconfig module.
*/
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,9 +36,10 @@ 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",
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..1940c94e 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,69 @@ 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()
}
+ 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 { writeFeedMeta(metaFile, it) }
+ remoteMeta?.let { metas -> writeFeedMeta(metaFile, feedUrls, metas) }
onStatus(GtfsIngestStatus.Ready)
}
@@ -112,15 +144,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 +197,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 +219,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 +240,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 +260,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 +275,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 +284,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 +302,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 +311,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 +322,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 +337,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 +354,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 +363,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 +379,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 +387,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(