mirror of
https://github.com/usetrmnl/trmnl-android.git
synced 2026-04-29 13:35:26 -07:00
Initial commit - application imported
Android application has been moved from https://github.com/hossain-khan/android-trmnl-display
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[*.{kt,kts}]
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Post Merge Check
|
||||
# Run extra build and checks to ensure `main` is functioning right.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
android-post-check:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '22'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
# Automatic gradle caching using `actions/cache@v4`
|
||||
# https://github.com/gradle/actions/tree/main/setup-gradle
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Run Linter
|
||||
run: ./gradlew lint
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
# Java JDK versions (LTS: 17, 21 & Non-LTS: 23)
|
||||
java-version: [ '17', '21', '23' ]
|
||||
# https://adoptium.net/temurin/releases/
|
||||
distribution: [ 'temurin' ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: ${{ matrix.java-version }}
|
||||
distribution: ${{ matrix.distribution }}
|
||||
|
||||
# Automatic gradle caching using `actions/cache@v4`
|
||||
# https://github.com/gradle/actions/tree/main/setup-gradle
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Run test
|
||||
run: ./gradlew testDebugUnitTest --parallel --daemon
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew assembleDebug --parallel --daemon
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
.DS_Store
|
||||
app/debug/
|
||||
app/release/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 TRMNL
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,115 @@
|
||||
[](https://github.com/usetrmnl/trmnl-android/actions/workflows/android.yml)
|
||||
|
||||
# Android - TRMNL Display
|
||||
A simple app to mirror existing TRMNL's content to your Android device (preferably e-ink display).
|
||||
|
||||
## 📜 Preconditions
|
||||
You must have a **valid** `access-token` to access the [screen content](https://docs.usetrmnl.com/go/private-api/fetch-screen-content) using TRMNL server API.
|
||||
|
||||
Here are some of the known ways you can get access to the `access-token`.
|
||||
|
||||
1. You must own a [TRMNL](https://usetrmnl.com/) device with "developer edition" add-on purchased
|
||||
2. You have purchased their [BYOD](https://docs.usetrmnl.com/go/diy/byod) product.
|
||||
3. You have self-serve installation of TRMNL service using [BYOS](https://docs.usetrmnl.com/go/diy/byos)
|
||||
|
||||
|
||||
## How to try
|
||||
⬇️ Install the APK on your Android device.
|
||||
|
||||
1. Configure the API `access-token` in the app settings
|
||||
i. 📝 NOTE: Right now only `https://trmnl.app/api/` service API is supported, custom service URL support will be added later
|
||||
2. Save the token and keep the app always-on with the TRMNL's display image showing.
|
||||
|
||||
### <img src="https://github.com/user-attachments/assets/64b4b132-a885-4783-98e3-c201bae6ccff" width="25"> Download Release
|
||||
Check installable APK from Assets in [latest release](https://github.com/usetrmnl/trmnl-android/releases).
|
||||
|
||||
<img alt="Demo Video" src="https://github.com/user-attachments/assets/2e98a5b4-fcd5-4aa9-bb57-43a8089919d6" width="500">
|
||||
|
||||
▶️ See [full demo](https://youtu.be/SRSwBphZTvs)
|
||||
|
||||
### Limitations 🚧
|
||||
1. Right now, screen lock using Google's [recommended](https://developer.android.com/develop/background-work/background-tasks/awake/screen-on) **`FLAG_KEEP_SCREEN_ON`** is not working on e-Ink tablet due to strict battery optimization. So, if you plan to keep the screen on indefinitely, you should set that in the device settings.
|
||||
* On normal Android tablet or device, screen wake lock should work. However it's not recommended to use it without device being always plugged-in 🔌.
|
||||
2. Currently the app uses Android WorkManager to schedule refresh job and it has minimum interval of ⏰ `15 min` between jobs. So, if your TRMNL is setup to refresh every `5 min`, you will not see it refresh until `15 min` is elapsed.
|
||||
* This can be overcome by using some clever logic or not using `WorkManager`. However, this is a OS optimized and reliable way to refresh image periodically.
|
||||
|
||||
|
||||
## Application Overview
|
||||
|
||||
The TRMNL Display Mirror app serves as a digital display mirror for TRMNL e-ink devices. The app connects to the TRMNL API, fetches display data, and shows it on Android devices.
|
||||
|
||||
### Key Features
|
||||
|
||||
- [x] Token-based authentication with the TRMNL API
|
||||
- [x] Automatic periodic image refresh from the server
|
||||
- [x] Adaptive refresh rate based on server response config
|
||||
- [x] Manual refresh capabilities
|
||||
- [x] Image caching for offline viewing
|
||||
- [x] Refresh history logging for tracking & validation
|
||||
|
||||
|
||||
## Android Developer Guide
|
||||
|
||||
This guide will help you get started with the TRMNL Display Android application development.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>See technical details on the project</summary>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio Meerkat or latest Android Studio (https://developer.android.com/studio)
|
||||
- JDK 17+
|
||||
- Git
|
||||
|
||||
### Getting Started
|
||||
|
||||
#### Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/usetrmnl/trmnl-android.git
|
||||
cd trmnl-android
|
||||
```
|
||||
|
||||
#### Open Project in Android Studio
|
||||
|
||||
1. Start Android Studio
|
||||
2. Select "Open an existing project"
|
||||
3. Navigate to and select the cloned repository folder
|
||||
|
||||
#### Build the Project
|
||||
|
||||
- Wait for the Gradle sync to complete
|
||||
- Build the project by selecting **Build > Make Project** or pressing **Ctrl+F9** (Windows/Linux) or **Cmd+F9** (macOS)
|
||||
|
||||
#### Run the App
|
||||
|
||||
- Connect an Android device or start an emulator
|
||||
- Click the Run button (green triangle) in the toolbar
|
||||
- Select your target device and click OK
|
||||
|
||||
### Project Structure
|
||||
|
||||
The app uses a modern Android architecture with the following components:
|
||||
|
||||
- **UI**: Jetpack Compose with Circuit UI architecture
|
||||
- **Background Processing**: WorkManager for scheduled image (re)loading
|
||||
- **Networking**: Retrofit and OkHttp for API communication
|
||||
- **DI**: Dagger with Anvil for dependency injection
|
||||
- **Data Storage**: DataStore for preferences and token storage
|
||||
|
||||
#### Key Features/Screens
|
||||
|
||||
- Main TRMNL Mirror Display visualization in `TrmnlMirrorDisplayScreen`
|
||||
- Settings management via `AppSettingsScreen`
|
||||
- Image refresh log/history in `DisplayRefreshLogScreen`
|
||||
- Background refresh scheduling with `TrmnlWorkScheduler` & `TrmnlImageRefreshWorker`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Related References 📖
|
||||
* https://usetrmnl.com/
|
||||
* https://usetrmnl.com/integrations
|
||||
* https://github.com/usetrmnl/trmnl-display
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,148 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.kotlinter)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.hossain.trmnl"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.hossain.trmnl"
|
||||
minSdk = 30
|
||||
targetSdk = 35
|
||||
versionCode = 9
|
||||
versionName = "1.7"
|
||||
|
||||
// Read key or other properties from local.properties
|
||||
val localProperties =
|
||||
project.rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use {
|
||||
Properties().apply { load(it) }
|
||||
}
|
||||
val apiKey = localProperties?.getProperty("SERVICE_API_KEY") ?: "MISSING-KEY"
|
||||
buildConfigField("String", "SERVICE_API_KEY", "\"$apiKey\"")
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.text.google.fonts)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.window)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
implementation(libs.circuit.codegen.annotations)
|
||||
implementation(libs.circuit.foundation)
|
||||
implementation(libs.circuit.overlay)
|
||||
implementation(libs.circuitx.android)
|
||||
implementation(libs.circuitx.effects)
|
||||
implementation(libs.circuitx.gestureNav)
|
||||
implementation(libs.circuitx.overlays)
|
||||
implementation(libs.androidx.compose.materialWindow)
|
||||
implementation(libs.androidx.adaptive)
|
||||
implementation(libs.core.ktx)
|
||||
ksp(libs.circuit.codegen)
|
||||
|
||||
implementation(libs.dagger)
|
||||
// Dagger KSP support is in Alpha, not available yet. Using KAPT for now.
|
||||
// https://dagger.dev/dev-guide/ksp.html
|
||||
kapt(libs.dagger.compiler)
|
||||
|
||||
implementation(libs.anvil.annotations)
|
||||
implementation(libs.anvil.annotations.optional)
|
||||
|
||||
// Timber
|
||||
implementation(libs.timber)
|
||||
|
||||
// Retrofit
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.gson)
|
||||
implementation(libs.retrofit.converter.moshi)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
// OkHttp
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging.interceptor)
|
||||
|
||||
// Moshi
|
||||
implementation(libs.moshi.kotlin)
|
||||
ksp(libs.moshi.kotlin.codegen)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// EitherNet
|
||||
implementation(libs.eithernet)
|
||||
implementation(libs.eithernet.integration.retrofit)
|
||||
|
||||
// Testing
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
|
||||
ksp {
|
||||
// Anvil-KSP
|
||||
arg("anvil-ksp-extraContributingAnnotations", "com.slack.circuit.codegen.annotations.CircuitInject")
|
||||
// kotlin-inject-anvil (requires 0.0.3+)
|
||||
arg("kotlin-inject-anvil-contributing-annotations", "com.slack.circuit.codegen.annotations.CircuitInject")
|
||||
}
|
||||
|
||||
// Enable dynamic agent loading for tests needed by MockK
|
||||
// https://github.com/hossain-khan/android-trmnl-display/pull/106#issuecomment-2826350990
|
||||
tasks.withType<Test> {
|
||||
jvmArgs("-XX:+EnableDynamicAgentLoading")
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.hossain.trmnl
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* 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("dev.hossain.trmnl", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
/**
|
||||
* Development configuration for the TRMNL app.
|
||||
*/
|
||||
object DevConfig {
|
||||
/**
|
||||
* Fake API response for local development and testing purposes.
|
||||
* In debug builds, we use fake API responses.
|
||||
*/
|
||||
const val FAKE_API_RESPONSE = true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".TrmnlDisplayMirrorApp"
|
||||
android:appComponentFactory=".di.TrmnlAppComponentFactory"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.App"
|
||||
tools:targetApi="35"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:replace="android:appComponentFactory">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,132 @@
|
||||
package dev.hossain.trmnl
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import com.slack.circuit.backstack.rememberSaveableBackStack
|
||||
import com.slack.circuit.foundation.Circuit
|
||||
import com.slack.circuit.foundation.CircuitCompositionLocals
|
||||
import com.slack.circuit.foundation.NavigableCircuitContent
|
||||
import com.slack.circuit.foundation.rememberCircuitNavigator
|
||||
import com.slack.circuit.overlay.ContentWithOverlays
|
||||
import com.slack.circuit.sharedelements.SharedElementTransitionLayout
|
||||
import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory
|
||||
import com.squareup.anvil.annotations.ContributesMultibinding
|
||||
import dev.hossain.trmnl.di.ActivityKey
|
||||
import dev.hossain.trmnl.di.AppScope
|
||||
import dev.hossain.trmnl.di.ApplicationContext
|
||||
import dev.hossain.trmnl.ui.display.TrmnlMirrorDisplayScreen
|
||||
import dev.hossain.trmnl.ui.theme.TrmnlDisplayAppTheme
|
||||
import dev.hossain.trmnl.work.TrmnlImageRefreshWorker
|
||||
import dev.hossain.trmnl.work.TrmnlImageUpdateManager
|
||||
import dev.hossain.trmnl.work.TrmnlWorkScheduler.Companion.IMAGE_REFRESH_ONETIME_WORK_NAME
|
||||
import dev.hossain.trmnl.work.TrmnlWorkScheduler.Companion.IMAGE_REFRESH_PERIODIC_WORK_NAME
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Main activity for the TRMNL display mirror app.
|
||||
* This activity sets up the Circuit framework and handles navigation.
|
||||
*/
|
||||
@ContributesMultibinding(AppScope::class, boundType = Activity::class)
|
||||
@ActivityKey(MainActivity::class)
|
||||
class MainActivity
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val circuit: Circuit,
|
||||
private val trmnlImageUpdateManager: TrmnlImageUpdateManager,
|
||||
) : ComponentActivity() {
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Setup listener for TRMNL display image updates
|
||||
listenForWorkUpdates()
|
||||
|
||||
setContent {
|
||||
TrmnlDisplayAppTheme {
|
||||
// See https://slackhq.github.io/circuit/navigation/
|
||||
val backStack = rememberSaveableBackStack(root = TrmnlMirrorDisplayScreen)
|
||||
val navigator = rememberCircuitNavigator(backStack)
|
||||
|
||||
// See https://slackhq.github.io/circuit/circuit-content/
|
||||
CircuitCompositionLocals(circuit) {
|
||||
// See https://slackhq.github.io/circuit/shared-elements/
|
||||
SharedElementTransitionLayout {
|
||||
// See https://slackhq.github.io/circuit/overlays/
|
||||
ContentWithOverlays {
|
||||
NavigableCircuitContent(
|
||||
navigator = navigator,
|
||||
backStack = backStack,
|
||||
decoratorFactory =
|
||||
remember(navigator) {
|
||||
GestureNavigationDecorationFactory(onBackInvoked = navigator::pop)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up observers for WorkManager work updates.
|
||||
*
|
||||
* This function:
|
||||
* 1. Listens for periodic image refresh work results
|
||||
* 2. Listens for one-time image refresh work results
|
||||
* 3. Updates the application with new images when available
|
||||
* 4. Logs work status and errors
|
||||
*/
|
||||
private fun listenForWorkUpdates() {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
workManager
|
||||
.getWorkInfosLiveData(
|
||||
WorkQuery.fromUniqueWorkNames(IMAGE_REFRESH_PERIODIC_WORK_NAME, IMAGE_REFRESH_ONETIME_WORK_NAME),
|
||||
).observe(this) { workInfos ->
|
||||
// ⚠️ DEV NOTE: On app launch, previously ran work info is broadcasted here,
|
||||
// so it may result in inconsistent behavior where it remembers last result.
|
||||
workInfos.forEach { workInfo ->
|
||||
when (workInfo.state) {
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
Timber.d("${workInfo.tags} work ${workInfo.state.name.lowercase()}: $workInfo")
|
||||
val newImageUrl =
|
||||
workInfo.outputData.getString(
|
||||
TrmnlImageRefreshWorker.KEY_NEW_IMAGE_URL,
|
||||
)
|
||||
|
||||
if (newImageUrl != null) {
|
||||
Timber.i("New image URL from ${workInfo.tags}: $newImageUrl")
|
||||
trmnlImageUpdateManager.updateImage(newImageUrl)
|
||||
}
|
||||
}
|
||||
WorkInfo.State.FAILED -> {
|
||||
val error = workInfo.outputData.getString(TrmnlImageRefreshWorker.KEY_ERROR_MESSAGE)
|
||||
Timber.e("${workInfo.tags} work failed: $error")
|
||||
trmnlImageUpdateManager.updateImage(imageUrl = "", errorMessage = error)
|
||||
}
|
||||
else -> {
|
||||
Timber.d("${workInfo.tags} work state updated: ${workInfo.state}")
|
||||
}
|
||||
}
|
||||
// Even though pruning is not recommended to do frequently,
|
||||
// we need this to avoid getting stale completed work info
|
||||
// See https://github.com/hossain-khan/android-trmnl-display/pull/98#issuecomment-2825920626
|
||||
// See https://github.com/hossain-khan/android-trmnl-display/pull/63#issuecomment-2817278344
|
||||
workManager.pruneWork()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.hossain.trmnl
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import dev.hossain.trmnl.di.AppComponent
|
||||
import dev.hossain.trmnl.work.TrmnlWorkerFactory
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Application class for the app with key initializations.
|
||||
*/
|
||||
class TrmnlDisplayMirrorApp :
|
||||
Application(),
|
||||
Configuration.Provider {
|
||||
private val appComponent: AppComponent by lazy { AppComponent.create(this) }
|
||||
|
||||
fun appComponent(): AppComponent = appComponent
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: TrmnlWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() {
|
||||
Timber.i("Setting up custom WorkManager configuration")
|
||||
return Configuration
|
||||
.Builder()
|
||||
.setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.WARN)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installLoggingTree()
|
||||
appComponent.inject(this)
|
||||
}
|
||||
|
||||
private fun installLoggingTree() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Plant a debug tree for development builds
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
/**
|
||||
* Global application configuration.
|
||||
*/
|
||||
object AppConfig {
|
||||
/**
|
||||
* Default display refresh rate in case the server does not provide one.
|
||||
*/
|
||||
const val DEFAULT_REFRESH_INTERVAL_SEC: Long = 7_200L // 2 hours
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Data class to store information about the last retrieved image
|
||||
*/
|
||||
data class ImageMetadata constructor(
|
||||
val url: String,
|
||||
/**
|
||||
* (OPTIONAL) Image refresh interval provided by API server
|
||||
*/
|
||||
val refreshIntervalSecs: Long? = null,
|
||||
/**
|
||||
* (OPTIONAL) Error message if the image retrieval failed
|
||||
*/
|
||||
val errorMessage: String? = null,
|
||||
val timestamp: Long = Instant.now().toEpochMilli(),
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dev.hossain.trmnl.di.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private val Context.imageDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "trmnl_image_metadata",
|
||||
)
|
||||
|
||||
/**
|
||||
* Store for image metadata, including URL, timestamp, and refresh interval.
|
||||
* These are used as cache to avoid unnecessary network calls.
|
||||
*
|
||||
* @see ImageMetadata
|
||||
*/
|
||||
class ImageMetadataStore
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private val IMAGE_URL_KEY = stringPreferencesKey("last_image_url")
|
||||
private val TIMESTAMP_KEY = longPreferencesKey("last_image_timestamp")
|
||||
private val REFRESH_RATE_KEY = longPreferencesKey("last_refresh_rate")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the image metadata as a Flow
|
||||
*/
|
||||
val imageMetadataFlow: Flow<ImageMetadata?> =
|
||||
context.imageDataStore.data.map { preferences ->
|
||||
val imageUrl = preferences[IMAGE_URL_KEY] ?: return@map null
|
||||
val timestamp = preferences[TIMESTAMP_KEY] ?: Instant.now().toEpochMilli()
|
||||
val refreshRate = preferences[REFRESH_RATE_KEY]
|
||||
|
||||
ImageMetadata(
|
||||
url = imageUrl,
|
||||
refreshIntervalSecs = refreshRate,
|
||||
errorMessage = null,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save new image metadata
|
||||
*/
|
||||
suspend fun saveImageMetadata(
|
||||
imageUrl: String,
|
||||
refreshIntervalSec: Long? = null,
|
||||
) {
|
||||
Timber.d("Saving image metadata: url=$imageUrl, refreshIntervalSec=$refreshIntervalSec")
|
||||
context.imageDataStore.edit { preferences ->
|
||||
preferences[IMAGE_URL_KEY] = imageUrl
|
||||
preferences[TIMESTAMP_KEY] = Instant.now().toEpochMilli()
|
||||
refreshIntervalSec?.let { preferences[REFRESH_RATE_KEY] = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the stored image URL is still valid based on refresh rate
|
||||
* @return Flow of Boolean indicating if a valid, non-expired image URL exists
|
||||
*/
|
||||
val hasValidImageUrlFlow: Flow<Boolean> =
|
||||
context.imageDataStore.data.map { preferences ->
|
||||
val url = preferences[IMAGE_URL_KEY] ?: return@map false
|
||||
val timestamp = preferences[TIMESTAMP_KEY] ?: return@map false
|
||||
val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map false
|
||||
|
||||
// Calculate if the image is expired based on timestamp + refresh rate
|
||||
val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds
|
||||
val currentTime = Instant.now().toEpochMilli()
|
||||
|
||||
// Image is valid if current time is before expiration
|
||||
url.isNotEmpty() && currentTime < expirationTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks synchronously if a valid, non-expired image URL exists
|
||||
* @return true if valid image URL exists and is not expired
|
||||
*/
|
||||
fun hasValidImageUrlSync(): Boolean {
|
||||
return runBlocking {
|
||||
return@runBlocking hasValidImageUrlFlow.first()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of time in milliseconds until the current image expires
|
||||
* @return Positive value if image is still valid, negative if already expired, null if no valid image
|
||||
*/
|
||||
val timeUntilExpirationFlow: Flow<Long?> =
|
||||
context.imageDataStore.data.map { preferences ->
|
||||
val timestamp = preferences[TIMESTAMP_KEY] ?: return@map null
|
||||
val refreshRate = preferences[REFRESH_RATE_KEY] ?: return@map null
|
||||
|
||||
// Calculate time until expiration
|
||||
val expirationTime = timestamp + (refreshRate * 1000) // Convert seconds to milliseconds
|
||||
val currentTime = Instant.now().toEpochMilli()
|
||||
|
||||
expirationTime - currentTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored image metadata
|
||||
*/
|
||||
suspend fun clearImageMetadata() {
|
||||
context.imageDataStore.edit { preferences ->
|
||||
preferences.remove(IMAGE_URL_KEY)
|
||||
preferences.remove(TIMESTAMP_KEY)
|
||||
preferences.remove(REFRESH_RATE_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Provides configuration for repository data sources. This class helps control whether repositories
|
||||
* should use fake/mock data instead of real API data, which is useful for development and testing.
|
||||
*/
|
||||
class RepositoryConfigProvider
|
||||
@Inject
|
||||
constructor() {
|
||||
/**
|
||||
* Indicates if the app should use fake data instead of real API responses.
|
||||
*
|
||||
* @return Boolean value from [DevConfig.FAKE_API_RESPONSE]
|
||||
*/
|
||||
val shouldUseFakeData: Boolean
|
||||
get() {
|
||||
return DevConfig.FAKE_API_RESPONSE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.hossain.trmnl.data
|
||||
|
||||
import dev.hossain.trmnl.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC
|
||||
|
||||
/**
|
||||
* Represents the display information for the TRMNL.
|
||||
* @see [TrmnlDisplayRepository]
|
||||
*/
|
||||
data class TrmnlDisplayInfo(
|
||||
val status: Int,
|
||||
val imageUrl: String,
|
||||
val imageName: String,
|
||||
val error: String? = null,
|
||||
val refreshIntervalSeconds: Long? = DEFAULT_REFRESH_INTERVAL_SEC,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user