Initial commit - application imported

Android application has been moved from https://github.com/hossain-khan/android-trmnl-display
This commit is contained in:
Hossain Khan
2025-04-29 09:29:43 -04:00
commit 46bc96beb4
120 changed files with 6307 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[*.{kt,kts}]
ktlint_function_naming_ignore_when_annotated_with = Composable
+31
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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/
+21
View File
@@ -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.
+115
View File
@@ -0,0 +1,115 @@
[![Android CI](https://github.com/usetrmnl/trmnl-android/actions/workflows/android.yml/badge.svg)](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
+1
View File
@@ -0,0 +1 @@
/build
+148
View File
@@ -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")
}
+21
View File
@@ -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
}
+43
View File
@@ -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