commit 46bc96beb4fba50ba72837d2a9e5c5a352803ed4 Author: Hossain Khan Date: Tue Apr 29 09:29:43 2025 -0400 Initial commit - application imported Android application has been moved from https://github.com/hossain-khan/android-trmnl-display diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c67144 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.{kt,kts}] +ktlint_function_naming_ignore_when_annotated_with = Composable \ No newline at end of file diff --git a/.github/workflows/android-lint.yml b/.github/workflows/android-lint.yml new file mode 100644 index 0000000..2b1b4ab --- /dev/null +++ b/.github/workflows/android-lint.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..1bea6cc --- /dev/null +++ b/.github/workflows/android.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4d6b5d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..403c538 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..194771f --- /dev/null +++ b/README.md @@ -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. + +### Download Release +Check installable APK from Assets in [latest release](https://github.com/usetrmnl/trmnl-android/releases). + +Demo Video + +▶️ 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. + +
+ +See technical details on the project + +## 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` + +
+ +--- + +## Related References 📖 +* https://usetrmnl.com/ +* https://usetrmnl.com/integrations +* https://github.com/usetrmnl/trmnl-display diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..81b3b38 --- /dev/null +++ b/app/build.gradle.kts @@ -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 { + jvmArgs("-XX:+EnableDynamicAgentLoading") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/dev/hossain/trmnl/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/hossain/trmnl/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..61bbe19 --- /dev/null +++ b/app/src/androidTest/java/dev/hossain/trmnl/ExampleInstrumentedTest.kt @@ -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) + } +} diff --git a/app/src/debug/java/dev/hossain/trmnl/data/DevConfig.kt b/app/src/debug/java/dev/hossain/trmnl/data/DevConfig.kt new file mode 100644 index 0000000..e5ee4ad --- /dev/null +++ b/app/src/debug/java/dev/hossain/trmnl/data/DevConfig.kt @@ -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 +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3d4ad91 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..f3746cf Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/dev/hossain/trmnl/MainActivity.kt b/app/src/main/java/dev/hossain/trmnl/MainActivity.kt new file mode 100644 index 0000000..47399f8 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/MainActivity.kt @@ -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() + } + } + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/TrmnlDisplayMirrorApp.kt b/app/src/main/java/dev/hossain/trmnl/TrmnlDisplayMirrorApp.kt new file mode 100644 index 0000000..f05b878 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/TrmnlDisplayMirrorApp.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/data/AppConfig.kt b/app/src/main/java/dev/hossain/trmnl/data/AppConfig.kt new file mode 100644 index 0000000..b52380f --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/AppConfig.kt @@ -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 +} diff --git a/app/src/main/java/dev/hossain/trmnl/data/ImageMetadata.kt b/app/src/main/java/dev/hossain/trmnl/data/ImageMetadata.kt new file mode 100644 index 0000000..b9082ea --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/ImageMetadata.kt @@ -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(), +) diff --git a/app/src/main/java/dev/hossain/trmnl/data/ImageMetadataStore.kt b/app/src/main/java/dev/hossain/trmnl/data/ImageMetadataStore.kt new file mode 100644 index 0000000..bb55d5b --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/ImageMetadataStore.kt @@ -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 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 = + 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 = + 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 = + 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) + } + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/data/RepositoryConfigProvider.kt b/app/src/main/java/dev/hossain/trmnl/data/RepositoryConfigProvider.kt new file mode 100644 index 0000000..efb8349 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/RepositoryConfigProvider.kt @@ -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 + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayInfo.kt b/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayInfo.kt new file mode 100644 index 0000000..e9f7579 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayInfo.kt @@ -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, +) diff --git a/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayRepository.kt b/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayRepository.kt new file mode 100644 index 0000000..2f5ac4d --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/TrmnlDisplayRepository.kt @@ -0,0 +1,140 @@ +package dev.hossain.trmnl.data + +import com.slack.eithernet.successOrNull +import com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.data.DevConfig.FAKE_API_RESPONSE +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.network.TrmnlApiService +import dev.hossain.trmnl.util.HTTP_200 +import dev.hossain.trmnl.util.HTTP_500 +import dev.hossain.trmnl.util.isHttpOk +import timber.log.Timber +import javax.inject.Inject + +/** + * Repository class responsible for fetching and mapping display data. + * + * ⚠️ NOTE: [FAKE_API_RESPONSE] is set to `true` in debug builds, meaning it will + * use mock data and avoid network calls. In release builds, it is set to `false` + * to enable real API calls. + * + * You can override this behavior by updating [DevConfig.FAKE_API_RESPONSE] for local development. + */ +@SingleIn(AppScope::class) +class TrmnlDisplayRepository + @Inject + constructor( + private val apiService: TrmnlApiService, + private val imageMetadataStore: ImageMetadataStore, + private val repositoryConfigProvider: RepositoryConfigProvider, + ) { + /** + * Fetches display data for next plugin from the server using the provided access token. + * If the app is in debug mode, it uses mock data instead. + * + * @param accessToken The access token for authentication. + * @return A [TrmnlDisplayInfo] object containing the display data. + */ + suspend fun getNextDisplayData(accessToken: String): TrmnlDisplayInfo { + if (repositoryConfigProvider.shouldUseFakeData) { + // Avoid using real API in debug mode + return fakeTrmnlDisplayInfo(apiUsed = "next-image") + } + + Timber.i("Fetching next playlist item display data from server") + + val response = apiService.getNextDisplayData(accessToken).successOrNull() + + // Map the response to the display info + val displayInfo = + TrmnlDisplayInfo( + status = response?.status ?: HTTP_500, + imageUrl = response?.imageUrl ?: "", + imageName = response?.imageName ?: "", + error = response?.error, + refreshIntervalSeconds = response?.refreshRate, + ) + + // If response was successful and has an image URL, save to data store + if (response?.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { + imageMetadataStore.saveImageMetadata( + displayInfo.imageUrl, + displayInfo.refreshIntervalSeconds, + ) + } + + return displayInfo + } + + /** + * Fetches the current display data from the server using the provided access token. + * If the app is in debug mode, it uses mock data instead. + * + * @param accessToken The access token for authentication. + * @return A [TrmnlDisplayInfo] object containing the current display data. + */ + suspend fun getCurrentDisplayData(accessToken: String): TrmnlDisplayInfo { + if (repositoryConfigProvider.shouldUseFakeData) { + // Avoid using real API in debug mode + return fakeTrmnlDisplayInfo(apiUsed = "current-image") + } + + Timber.i("Fetching current display data from server") + + val response = apiService.getCurrentDisplayData(accessToken).successOrNull() + + // Map the response to the display info + val displayInfo = + TrmnlDisplayInfo( + status = response?.status ?: HTTP_500, + imageUrl = response?.imageUrl ?: "", + imageName = response?.filename ?: "", + error = response?.error, + refreshIntervalSeconds = response?.refreshRateSec, + ) + + // If response was successful and has an image URL, save to data store + if (response?.status.isHttpOk() && displayInfo.imageUrl.isNotEmpty()) { + imageMetadataStore.saveImageMetadata( + displayInfo.imageUrl, + displayInfo.refreshIntervalSeconds, + ) + } + + return displayInfo + } + + /** + * Check the server status by making a request to the log endpoint. + */ + suspend fun checkServerStatus(): Boolean { + val response = apiService.getLog("RANDOM-ID", "RANDOM-TOKEN").successOrNull() + Timber.d("Device log status response: $response") + + // If we got 200 OK, we assume the server is up + return response != null + } + + /** + * Generates fake display info for debugging purposes without wasting an API request. + * + * ℹ️ This is only used when [FAKE_API_RESPONSE] is set to `true`. + */ + private suspend fun fakeTrmnlDisplayInfo(apiUsed: String): TrmnlDisplayInfo { + Timber.d("DEBUG: Using mock data for display info") + val timestampMin = System.currentTimeMillis() / 60_000 // Changes every minute + val mockImageUrl = "https://picsum.photos/300/200?grayscale&time=$timestampMin&api=$apiUsed" + val mockRefreshRate = 600L + + // Save mock data to the data store + imageMetadataStore.saveImageMetadata(mockImageUrl, mockRefreshRate) + + return TrmnlDisplayInfo( + status = HTTP_200, + imageUrl = mockImageUrl, + imageName = "mocked-image-" + mockImageUrl.substringAfterLast('?'), + error = null, + refreshIntervalSeconds = mockRefreshRate, + ) + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/data/TrmnlTokenDataStore.kt b/app/src/main/java/dev/hossain/trmnl/data/TrmnlTokenDataStore.kt new file mode 100644 index 0000000..c490114 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/TrmnlTokenDataStore.kt @@ -0,0 +1,149 @@ +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 com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.di.AppScope +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 javax.inject.Inject + +/** + * DataStore for managing access tokens and refresh rate settings. + */ +private val Context.tokenDataStore: DataStore by preferencesDataStore( + name = "trmnl_token_store", +) + +/** + * Manages token and refresh rate settings for the TRMNL application. + * + * This class provides methods for storing, retrieving, and managing the access token + * and refresh rate settings using Android's DataStore. It offers both Flow-based + * asynchronous APIs and synchronous blocking alternatives for different usage scenarios. + * + * The token manager is scoped to the application lifecycle and serves as the + * source of truth for authentication and refresh interval configuration. + */ +@SingleIn(AppScope::class) +class TrmnlTokenDataStore + @Inject + constructor( + @ApplicationContext private val context: Context, + ) { + companion object { + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val REFRESH_RATE_SEC_KEY = longPreferencesKey("refresh_rate_seconds") + } + + /** + * Gets the access token as a Flow + */ + val accessTokenFlow: Flow = + context.tokenDataStore.data.map { preferences -> + preferences[ACCESS_TOKEN_KEY] + } + + /** + * Gets the refresh rate in seconds as a Flow + */ + val refreshRateSecondsFlow: Flow = + context.tokenDataStore.data.map { preferences -> + preferences[REFRESH_RATE_SEC_KEY] + } + + /** + * Saves the access token to DataStore + */ + suspend fun saveAccessToken(token: String) { + context.tokenDataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = token + } + } + + /** + * Saves the refresh rate in seconds to DataStore + */ + suspend fun saveRefreshRateSeconds(seconds: Long) { + context.tokenDataStore.edit { preferences -> + preferences[REFRESH_RATE_SEC_KEY] = seconds + } + } + + /** + * Gets the access token synchronously (blocking) + */ + fun getAccessTokenSync(): String? { + return runBlocking { + return@runBlocking accessTokenFlow.first() + } + } + + /** + * Gets the refresh rate in seconds synchronously (blocking) + */ + fun getRefreshRateSecondsSync(): Long? { + return runBlocking { + return@runBlocking refreshRateSecondsFlow.first() + } + } + + /** + * Checks if a token is already set + * @return Flow of Boolean indicating if token exists and is not empty + */ + val hasTokenFlow: Flow = + accessTokenFlow.map { token -> + !token.isNullOrBlank() + } + + /** + * Checks synchronously if token is already set + * @return true if token exists and is not empty + */ + fun hasTokenSync(): Boolean { + return runBlocking { + return@runBlocking hasTokenFlow.first() + } + } + + /** + * Clears the access token + */ + suspend fun clearAccessToken() { + context.tokenDataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN_KEY) + } + } + + /** + * Clears the refresh rate settings + */ + suspend fun clearRefreshRateSeconds() { + context.tokenDataStore.edit { preferences -> + preferences.remove(REFRESH_RATE_SEC_KEY) + } + } + + /** + * Clears all stored preferences + */ + suspend fun clearAll() { + context.tokenDataStore.edit { preferences -> + preferences.clear() + } + } + + suspend fun shouldUpdateRefreshRate(newRefreshRateSec: Long): Boolean { + val currentRefreshRate = refreshRateSecondsFlow.first() + return currentRefreshRate != null && newRefreshRateSec != currentRefreshRate + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLog.kt b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLog.kt new file mode 100644 index 0000000..742f56d --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLog.kt @@ -0,0 +1,44 @@ +package dev.hossain.trmnl.data.log + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.time.Instant + +@JsonClass(generateAdapter = true) +data class TrmnlRefreshLog( + @Json(name = "timestamp") val timestamp: Long, + @Json(name = "imageUrl") val imageUrl: String?, + @Json(name = "imageName") val imageName: String?, + @Json(name = "refreshRateSeconds") val refreshIntervalSeconds: Long?, + @Json(name = "success") val success: Boolean, + @Json(name = "error") val error: String? = null, + @Json(name = "refreshWorkType") val imageRefreshWorkType: String? = null, +) { + companion object { + fun createSuccess( + imageUrl: String, + imageName: String, + refreshIntervalSeconds: Long?, + imageRefreshWorkType: String?, + ): TrmnlRefreshLog = + TrmnlRefreshLog( + timestamp = Instant.now().toEpochMilli(), + imageUrl = imageUrl, + imageName = imageName, + refreshIntervalSeconds = refreshIntervalSeconds, + success = true, + imageRefreshWorkType = imageRefreshWorkType, + ) + + fun createFailure(error: String): TrmnlRefreshLog = + TrmnlRefreshLog( + timestamp = Instant.now().toEpochMilli(), + imageUrl = null, + imageName = null, + refreshIntervalSeconds = null, + success = false, + error = error, + imageRefreshWorkType = null, + ) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogManager.kt b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogManager.kt new file mode 100644 index 0000000..90da92f --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogManager.kt @@ -0,0 +1,64 @@ +package dev.hossain.trmnl.data.log + +import android.content.Context +import androidx.datastore.core.DataStore +import com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +@SingleIn(AppScope::class) +class TrmnlRefreshLogManager + @Inject + constructor( + @ApplicationContext private val context: Context, + private val dataStore: DataStore, + ) { + companion object { + const val MAX_LOG_ENTRIES = 100 + } + + val logsFlow: Flow> = + dataStore.data + .catch { e -> + Timber.e(e, "Error reading logs") + emit(TrmnlRefreshLogs()) + }.map { it.logs } + + suspend fun addSuccessLog( + imageUrl: String, + imageName: String, + refreshIntervalSeconds: Long?, + imageRefreshWorkType: String?, + ) { + addLog(TrmnlRefreshLog.createSuccess(imageUrl, imageName, refreshIntervalSeconds, imageRefreshWorkType)) + } + + suspend fun addFailureLog(error: String) { + addLog(TrmnlRefreshLog.createFailure(error)) + } + + internal suspend fun addLog(log: TrmnlRefreshLog) { + dataStore.updateData { currentLogs -> + val updatedLogs = + currentLogs.logs.toMutableList().apply { + add(0, log) // Add to the beginning for descending order + if (size > MAX_LOG_ENTRIES) { + // Keep only the most recent logs + removeAll(subList(MAX_LOG_ENTRIES, size)) + } + } + TrmnlRefreshLogs(updatedLogs) + } + } + + suspend fun clearLogs() { + dataStore.updateData { + TrmnlRefreshLogs(emptyList()) + } + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogSerializer.kt b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogSerializer.kt new file mode 100644 index 0000000..398aa41 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogSerializer.kt @@ -0,0 +1,42 @@ +package dev.hossain.trmnl.data.log + +import androidx.datastore.core.Serializer +import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.InputStream +import java.io.OutputStream + +object TrmnlRefreshLogSerializer : Serializer { + private val moshi = Moshi.Builder().build() + private val adapter = moshi.adapter(TrmnlRefreshLogs::class.java) + + override val defaultValue: TrmnlRefreshLogs + get() = TrmnlRefreshLogs(emptyList()) + + override suspend fun readFrom(input: InputStream): TrmnlRefreshLogs = + withContext(Dispatchers.IO) { + try { + val jsonString = input.readBytes().decodeToString() + adapter.fromJson(jsonString) ?: defaultValue + } catch (e: Exception) { + Timber.e(e, "Error reading activity logs") + defaultValue + } + } + + override suspend fun writeTo( + t: TrmnlRefreshLogs, + output: OutputStream, + ) { + withContext(Dispatchers.IO) { + try { + val jsonString = adapter.toJson(t) + output.write(jsonString.toByteArray()) + } catch (e: Exception) { + Timber.e(e, "Error writing activity logs") + } + } + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogs.kt b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogs.kt new file mode 100644 index 0000000..10b3428 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/data/log/TrmnlRefreshLogs.kt @@ -0,0 +1,8 @@ +package dev.hossain.trmnl.data.log + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TrmnlRefreshLogs( + val logs: List = emptyList(), +) diff --git a/app/src/main/java/dev/hossain/trmnl/di/ActivityKey.kt b/app/src/main/java/dev/hossain/trmnl/di/ActivityKey.kt new file mode 100644 index 0000000..ae842ef --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/ActivityKey.kt @@ -0,0 +1,12 @@ +package dev.hossain.trmnl.di + +import android.app.Activity +import dagger.MapKey +import kotlin.reflect.KClass + +/** + * A Dagger multi-binding key used for registering a [Activity] into the top level dagger graphs. + */ +@MapKey annotation class ActivityKey( + val value: KClass, +) diff --git a/app/src/main/java/dev/hossain/trmnl/di/AppComponent.kt b/app/src/main/java/dev/hossain/trmnl/di/AppComponent.kt new file mode 100644 index 0000000..6977509 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/AppComponent.kt @@ -0,0 +1,34 @@ +package dev.hossain.trmnl.di + +import android.app.Activity +import android.content.Context +import com.squareup.anvil.annotations.MergeComponent +import com.squareup.anvil.annotations.optional.SingleIn +import dagger.BindsInstance +import dev.hossain.trmnl.TrmnlDisplayMirrorApp +import javax.inject.Provider + +@MergeComponent( + scope = AppScope::class, + modules = [CircuitModule::class], +) +@SingleIn(AppScope::class) +interface AppComponent { + val activityProviders: Map, @JvmSuppressWildcards Provider> + + /** + * Injects dependencies into [TrmnlDisplayMirrorApp]. + */ + fun inject(app: TrmnlDisplayMirrorApp) + + @MergeComponent.Factory + interface Factory { + fun create( + @ApplicationContext @BindsInstance context: Context, + ): AppComponent + } + + companion object { + fun create(context: Context): AppComponent = DaggerAppComponent.factory().create(context) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/di/AppScope.kt b/app/src/main/java/dev/hossain/trmnl/di/AppScope.kt new file mode 100644 index 0000000..5f49c66 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/AppScope.kt @@ -0,0 +1,9 @@ +package dev.hossain.trmnl.di + +/** + * App scope for dependency injection. + * + * - https://github.com/square/anvil?tab=readme-ov-file#scopes + * - https://github.com/square/anvil/blob/main/sample/scopes/src/main/java/com/squareup/scopes/AppScope.kt + */ +abstract class AppScope private constructor() diff --git a/app/src/main/java/dev/hossain/trmnl/di/ApplicationContext.kt b/app/src/main/java/dev/hossain/trmnl/di/ApplicationContext.kt new file mode 100644 index 0000000..2e0e4a5 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/ApplicationContext.kt @@ -0,0 +1,6 @@ +package dev.hossain.trmnl.di + +import javax.inject.Qualifier + +/** Qualifier to denote a `Context` that is specifically an Application context. */ +@Qualifier annotation class ApplicationContext diff --git a/app/src/main/java/dev/hossain/trmnl/di/CircuitModule.kt b/app/src/main/java/dev/hossain/trmnl/di/CircuitModule.kt new file mode 100644 index 0000000..8c6db4e --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/CircuitModule.kt @@ -0,0 +1,44 @@ +package dev.hossain.trmnl.di + +import com.slack.circuit.foundation.Circuit +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.ui.Ui +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.optional.SingleIn +import dagger.Module +import dagger.Provides +import dagger.multibindings.Multibinds + +/** + * Dagger module that provides dependencies for the Circuit framework. + */ +@ContributesTo(AppScope::class) +@Module +interface CircuitModule { + /** + * Dagger multi-binding method that provides a set of Presenter.Factory instances. + */ + @Multibinds fun presenterFactories(): Set + + /** + * Dagger multi-binding method that provides a set of Ui.Factory instances. + */ + @Multibinds fun viewFactories(): Set + + companion object { + /** + * Provides a singleton instance of Circuit with presenter and ui configured. + */ + @SingleIn(AppScope::class) + @Provides + fun provideCircuit( + presenterFactories: @JvmSuppressWildcards Set, + uiFactories: @JvmSuppressWildcards Set, + ): Circuit = + Circuit + .Builder() + .addPresenterFactories(presenterFactories) + .addUiFactories(uiFactories) + .build() + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/di/DataStoreModule.kt b/app/src/main/java/dev/hossain/trmnl/di/DataStoreModule.kt new file mode 100644 index 0000000..937536d --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/DataStoreModule.kt @@ -0,0 +1,31 @@ +package dev.hossain.trmnl.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.optional.SingleIn +import dagger.Module +import dagger.Provides +import dev.hossain.trmnl.data.log.TrmnlRefreshLogSerializer +import dev.hossain.trmnl.data.log.TrmnlRefreshLogs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@ContributesTo(AppScope::class) +object DataStoreModule { + @Provides + @SingleIn(AppScope::class) + fun provideActivityLogDataStore( + @ApplicationContext context: Context, + ): DataStore = + DataStoreFactory.create( + serializer = TrmnlRefreshLogSerializer, + produceFile = { context.dataStoreFile("trmnl_activity_logs.json") }, + corruptionHandler = null, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ) +} diff --git a/app/src/main/java/dev/hossain/trmnl/di/NetworkModule.kt b/app/src/main/java/dev/hossain/trmnl/di/NetworkModule.kt new file mode 100644 index 0000000..28f4cc3 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/NetworkModule.kt @@ -0,0 +1,99 @@ +package dev.hossain.trmnl.di + +import android.content.Context +import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory +import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.optional.SingleIn +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dev.hossain.trmnl.network.TrmnlApiService +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.io.File +import java.util.concurrent.TimeUnit + +@Module +@ContributesTo(AppScope::class) +object NetworkModule { + // Cache size for OkHttp (10 MB) + private const val CACHE_SIZE = 10 * 1024 * 1024L + + /** + * New base URL for the TRMNL API server. + * Ref: https://discord.com/channels/1281055965508141100/1284986536357662740/1364623667337760910 + */ + private const val TRMNL_API_SERVER_BASE_URL = "https://trmnl.app/" + + @Provides + @SingleIn(AppScope::class) + fun provideOkHttpClient( + @ApplicationContext context: Context, + ): OkHttpClient { + val loggingInterceptor = + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + // Create cache directory + val cacheDir = File(context.cacheDir, "http_cache") + val cache = Cache(cacheDir, CACHE_SIZE) + + // Create custom user agent with app info and version + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val appName = context.getString(context.applicationInfo.labelRes) + val versionName = packageInfo.versionName + val versionCode = packageInfo.longVersionCode + + val userAgent = "$appName/$versionName (build:$versionCode; Android ${android.os.Build.VERSION.RELEASE})" + + return OkHttpClient + .Builder() + .addInterceptor { chain -> + val request = + chain + .request() + .newBuilder() + .header("User-Agent", userAgent) + .build() + chain.proceed(request) + }.addInterceptor(loggingInterceptor) + .cache(cache) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @SingleIn(AppScope::class) + fun provideMoshi(): Moshi = + Moshi + .Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + @Provides + @SingleIn(AppScope::class) + fun provideRetrofit( + okHttpClient: OkHttpClient, + moshi: Moshi, + ): Retrofit = + Retrofit + .Builder() + .baseUrl(TRMNL_API_SERVER_BASE_URL) + .client(okHttpClient) + .addConverterFactory(ApiResultConverterFactory) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(ApiResultCallAdapterFactory) + .build() + + @Provides + @SingleIn(AppScope::class) + fun provideTrmnlApiService(retrofit: Retrofit): TrmnlApiService = retrofit.create(TrmnlApiService::class.java) +} diff --git a/app/src/main/java/dev/hossain/trmnl/di/TrmnlAppComponentFactory.kt b/app/src/main/java/dev/hossain/trmnl/di/TrmnlAppComponentFactory.kt new file mode 100644 index 0000000..732c55b --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/TrmnlAppComponentFactory.kt @@ -0,0 +1,100 @@ +package dev.hossain.trmnl.di + +import android.app.Activity +import android.app.Application +import android.content.Intent +import androidx.annotation.Keep +import androidx.core.app.AppComponentFactory +import dev.hossain.trmnl.TrmnlDisplayMirrorApp +import javax.inject.Provider + +/** + * Custom implementation of [AppComponentFactory] used to inject Android components + * (specifically Activities) via Dagger using constructor injection. This factory + * allows the Android system to delegate activity instantiation to Dagger's dependency + * graph, enabling constructor injection instead of field injection. + * + * This class is referenced in the `AndroidManifest` within the `` tag. + * + * Usage: + * Add the following to your AndroidManifest.xml: + * + * ```xml + * + * ``` + */ +@Keep +class TrmnlAppComponentFactory : AppComponentFactory() { + /** + * Retrieves an instance of the specified class (typically an Activity) from the provided + * Dagger providers map. If a provider exists for the class, it uses that provider to + * obtain the instance; otherwise, it returns null. + * + * @param T The type of the class being retrieved. + * @param classLoader The ClassLoader used to load the class. + * @param className The fully qualified name of the class to be instantiated. + * @param providers A map containing Dagger providers for the available classes. + * @return The instance of the class if found in the providers map, or null if not. + */ + private inline fun getInstance( + classLoader: ClassLoader, + className: String, + providers: Map, @JvmSuppressWildcards Provider>, + ): T? { + // Load the class using the provided ClassLoader and attempt to retrieve the instance. + val clazz = Class.forName(className, false, classLoader).asSubclass(T::class.java) + val modelProvider = providers[clazz] ?: return null + return modelProvider.get() as T + } + + /** + * Called by the Android system to instantiate an activity. This method checks if the + * activity can be provided by the Dagger component. If the Dagger component can provide + * the activity, it returns the injected instance; otherwise, it falls back to the + * default system behavior. + * + * @param classLoader The ClassLoader used to load the activity class. + * @param className The fully qualified name of the activity to be instantiated. + * @param intent The intent that was used to start the activity. + * @return The activity instance, either from Dagger or from the system. + */ + override fun instantiateActivityCompat( + classLoader: ClassLoader, + className: String, + intent: Intent?, + ): Activity = + getInstance(classLoader, className, activityProviders) + ?: super.instantiateActivityCompat(classLoader, className, intent) + + /** + * Called by the Android system to instantiate the Application class. This method + * initializes the Dagger component and retrieves the map of activity providers, + * which are used later for activity injection. + * + * @param classLoader The ClassLoader used to load the Application class. + * @param className The fully qualified name of the Application class. + * @return The Application instance. + */ + override fun instantiateApplicationCompat( + classLoader: ClassLoader, + className: String, + ): Application { + val app = super.instantiateApplicationCompat(classLoader, className) + // Retrieve the Dagger app component and the activity providers from it + activityProviders = (app as TrmnlDisplayMirrorApp).appComponent().activityProviders + return app + } + + /** + * Companion object to store activity providers. This object holds the Dagger-provided + * map of activity classes to their corresponding providers. It's used to inject activities + * upon instantiation. + * + * This map is initialized when the application is created via the Dagger component. + */ + companion object { + private lateinit var activityProviders: Map, Provider> + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/di/WorkerModule.kt b/app/src/main/java/dev/hossain/trmnl/di/WorkerModule.kt new file mode 100644 index 0000000..cac6885 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/di/WorkerModule.kt @@ -0,0 +1,30 @@ +package dev.hossain.trmnl.di + +import android.content.Context +import androidx.work.Configuration +import androidx.work.WorkManager +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.optional.SingleIn +import dagger.Module +import dagger.Provides +import dev.hossain.trmnl.work.TrmnlWorkerFactory + +@Module +@ContributesTo(AppScope::class) +object WorkerModule { + @Provides + @SingleIn(AppScope::class) + fun provideWorkManager( + @ApplicationContext context: Context, + factory: TrmnlWorkerFactory, + ): WorkManager { + val config = + Configuration + .Builder() + .setWorkerFactory(factory) + .build() + + WorkManager.initialize(context, config) + return WorkManager.getInstance(context) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/network/TrmnlApiService.kt b/app/src/main/java/dev/hossain/trmnl/network/TrmnlApiService.kt new file mode 100644 index 0000000..3e71e42 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/network/TrmnlApiService.kt @@ -0,0 +1,48 @@ +package dev.hossain.trmnl.network + +import com.slack.eithernet.ApiResult +import dev.hossain.trmnl.network.model.TrmnlCurrentImageResponse +import dev.hossain.trmnl.network.model.TrmnlDisplayResponse +import dev.hossain.trmnl.network.model.TrmnlLogResponse +import retrofit2.http.GET +import retrofit2.http.Header + +/** + * API service interface for TRMNL. + * + * This interface defines the endpoints for the TRMNL API. + * + * See: + * - https://docs.usetrmnl.com/go + */ +interface TrmnlApiService { + /** + * Retrieve TRMNL image data, device-free. + * + * NOTE: This API always loads the next plugin image from the playlist. + * + * @see getCurrentDisplayData + */ + @GET("api/display") + suspend fun getNextDisplayData( + @Header("access-token") accessToken: String, + ): ApiResult + + /** + * Retrieve TRMNL image that is currently being displayed. + * + * NOTE: This API always loads the current plugin image from the playlist. + * + * @see getNextDisplayData + */ + @GET("api/current_screen") + suspend fun getCurrentDisplayData( + @Header("access-token") accessToken: String, + ): ApiResult + + @GET("api/log") + suspend fun getLog( + @Header("ID") id: String, + @Header("Access-Token") accessToken: String, + ): ApiResult +} diff --git a/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlCurrentImageResponse.kt b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlCurrentImageResponse.kt new file mode 100644 index 0000000..970faa1 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlCurrentImageResponse.kt @@ -0,0 +1,46 @@ +package dev.hossain.trmnl.network.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Data class representing the response from the TRMNL API for the current image. + * + * Sample response: + * ```json + * { + * "status": 200, + * "refresh_rate": 3590, + * "image_url": "https://usetrmnl.com/plugin-image.bmp", + * "filename": "plugin-image", + * "rendered_at": null + * } + * ``` + * + * Sample error response: + * ```json + * { + * "status": 500, + * "error": "Device not found", + * "reset_firmware": true + * } + * ``` + */ +@JsonClass(generateAdapter = true) +data class TrmnlCurrentImageResponse( + val status: Int, + @Json(name = "refresh_rate") + val refreshRateSec: Long?, + @Json(name = "image_url") + val imageUrl: String?, + val filename: String?, + /** + * Timestamp when the image was rendered. + */ + @Json(name = "rendered_at") + val renderedAt: Long?, + /** + * Error message for non 200 status codes. + */ + val error: String?, +) diff --git a/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlDisplayResponse.kt b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlDisplayResponse.kt new file mode 100644 index 0000000..b813bb7 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlDisplayResponse.kt @@ -0,0 +1,49 @@ +package dev.hossain.trmnl.network.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Data class representing the response from the TRMNL API for display data. + * + * Sample success response: + * ```json + * { + * "status": 0, + * "image_url": "https://trmnl.s3.us-east-2.amazonaws.com/...0f", + * "filename": "plugin-2025-....4640540", + * "refresh_rate": 3595, + * "reset_firmware": false, + * "update_firmware": true, + * "firmware_url": "https://trmnl-fw.s3.us-east-2.amazonaws.com/FW1.4.8.bin", + * "special_function": "restart_playlist" + * } + * ``` + * + * Sample response when device is sleeping: + * ```json + * { + * "status": 0, + * "image_url": "https://usetrmnl.com/images/setup/sleep.bmp", + * "filename": "sleep", + * "refresh_rate": 28498 + * } + * ``` + * + * Sample fail response: + * ```json + * {"status":500,"error":"Device not found","reset_firmware":true} + * ``` + */ +@JsonClass(generateAdapter = true) +data class TrmnlDisplayResponse( + val status: Int, + @Json(name = "image_url") val imageUrl: String?, + @Json(name = "filename") val imageName: String?, + @Json(name = "update_firmware") val updateFirmware: Boolean?, + @Json(name = "firmware_url") val firmwareUrl: String?, + @Json(name = "refresh_rate") val refreshRate: Long?, + @Json(name = "reset_firmware") val resetFirmware: Boolean?, + val error: String? = null, // Added for error responses + @Json(name = "special_function") val specialFunction: String? = null, +) diff --git a/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlLogResponse.kt b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlLogResponse.kt new file mode 100644 index 0000000..6655498 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/network/model/TrmnlLogResponse.kt @@ -0,0 +1,11 @@ +package dev.hossain.trmnl.network.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TrmnlLogResponse( + @Json(name = "status") val status: Int, + @Json(name = "error") val error: String? = null, + // Add other fields if available in the actual response +) diff --git a/app/src/main/java/dev/hossain/trmnl/ui/FullScreenMode.kt b/app/src/main/java/dev/hossain/trmnl/ui/FullScreenMode.kt new file mode 100644 index 0000000..529ee04 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/FullScreenMode.kt @@ -0,0 +1,58 @@ +package dev.hossain.trmnl.ui + +import android.app.Activity +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +/** + * A utility composable that handles fullscreen mode for the current screen. + * Hides system bars (status bar and navigation bar) when active. + * + * It also keeps the screen awake, useful for showing TRMNL screens and updates. + */ +@Composable +fun FullScreenMode( + enabled: Boolean = true, + keepScreenOn: Boolean = false, +) { + val context = LocalContext.current + val activity = context as? Activity ?: return + + DisposableEffect(enabled, keepScreenOn) { + val window = activity.window + val controller = WindowCompat.getInsetsController(window, window.decorView) + + if (enabled) { + // Make content draw behind system bars + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Hide system bars + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + // Handle screen wake lock + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + onDispose { + if (enabled) { + // Restore normal UI visibility when the composable leaves composition + WindowCompat.setDecorFitsSystemWindows(window, true) + controller.show(WindowInsetsCompat.Type.systemBars()) + } + + // Clear wake lock if it was set + if (keepScreenOn) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/ui/display/TrmnlMirrorDisplayScreen.kt b/app/src/main/java/dev/hossain/trmnl/ui/display/TrmnlMirrorDisplayScreen.kt new file mode 100644 index 0000000..c7a8151 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/display/TrmnlMirrorDisplayScreen.kt @@ -0,0 +1,488 @@ +package dev.hossain.trmnl.ui.display + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND +import coil3.compose.AsyncImage +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dev.hossain.trmnl.R +import dev.hossain.trmnl.data.ImageMetadataStore +import dev.hossain.trmnl.data.TrmnlTokenDataStore +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.ui.FullScreenMode +import dev.hossain.trmnl.ui.refreshlog.DisplayRefreshLogScreen +import dev.hossain.trmnl.ui.settings.AppSettingsScreen +import dev.hossain.trmnl.util.CoilRequestUtils +import dev.hossain.trmnl.util.nextRunTime +import dev.hossain.trmnl.work.TrmnlImageUpdateManager +import dev.hossain.trmnl.work.TrmnlWorkScheduler +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +/** + * This is the full screen view to show TRMNL's bitmap image for other e-ink display, + * or any other devices like phone or tablet. + */ +@Parcelize +data object TrmnlMirrorDisplayScreen : Screen { + data class State( + val imageUrl: String?, + val overlayControlsVisible: Boolean, + val nextImageRefreshIn: String, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val eventSink: (Event) -> Unit, + ) : CircuitUiState + + sealed class Event : CircuitUiEvent { + data object RefreshCurrentPlaylistItemRequested : Event() + + data object LoadNextPlaylistItemImage : Event() + + data object ConfigureRequested : Event() + + data object ViewLogsRequested : Event() + + data object BackPressed : Event() + + data object ToggleOverlayControls : Event() + } +} + +class TrmnlMirrorDisplayPresenter + @AssistedInject + constructor( + @Assisted private val navigator: Navigator, + private val trmnlTokenDataStore: TrmnlTokenDataStore, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val imageMetadataStore: ImageMetadataStore, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, + ) : Presenter { + @Composable + override fun present(): TrmnlMirrorDisplayScreen.State { + var imageUrl by remember { mutableStateOf(null) } + var overlayControlsVisible by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(true) } + var nextRefreshTime by remember { mutableStateOf("No scheduled work found. Please set API token.") } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + // Collect updates from the image update manager to get the latest image URL + // Latest image URL is received from WorkManager work requests. + LaunchedEffect(Unit) { + trmnlImageUpdateManager.imageUpdateFlow.collect { imageMetadata -> + Timber.d("Received new image URL from TRMNL Image Update Manager: $imageMetadata") + if (imageMetadata != null && imageMetadata.errorMessage == null) { + imageUrl = imageMetadata.url + isLoading = false + error = null + } else { + Timber.w("Failed to get cached image URL from TRMNL Image Update Manager `imageUpdateFlow`") + isLoading = false + error = imageMetadata?.errorMessage ?: "Failed to load image. Please re-validate token." + } + } + } + + // Auto-hide timer for overlay controls + LaunchedEffect(overlayControlsVisible) { + if (overlayControlsVisible) { + delay(3_000) // Hide controls after 3 seconds + overlayControlsVisible = false + } + } + + LaunchedEffect(overlayControlsVisible) { + trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> + workInfo?.nextRunTime()?.let { + nextRefreshTime = it.timeUntilNextRefresh + } ?: "No scheduled work found. Please set API token." + } + } + + // Initialize by checking token and starting one-time work if needed + LaunchedEffect(Unit) { + val token = trmnlTokenDataStore.accessTokenFlow.firstOrNull() + if (token.isNullOrBlank()) { + Timber.d("No access token found, navigating to configuration screen") + navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) + return@LaunchedEffect + } + + // Check if we have a cached image + val hasValidImage = imageMetadataStore.hasValidImageUrlFlow.firstOrNull() ?: false + if (hasValidImage) { + // Initial loading state will be updated when imageUpdateFlow emits + Timber.d("Valid cached image URL exists in ImageMetadataStore") + trmnlImageUpdateManager.initialize() + } else { + // No valid image, start a refresh work + Timber.d("No valid cached image, starting one-time refresh work") + trmnlWorkScheduler.startOneTimeImageRefreshWork() + } + } + + return TrmnlMirrorDisplayScreen.State( + imageUrl = imageUrl, + overlayControlsVisible = overlayControlsVisible, + nextImageRefreshIn = nextRefreshTime, + isLoading = isLoading, + errorMessage = error, + eventSink = { event -> + when (event) { + TrmnlMirrorDisplayScreen.Event.RefreshCurrentPlaylistItemRequested -> { + overlayControlsVisible = false + // Simply trigger the worker for refresh + scope.launch { + // Clear the image URL so that when the image is refreshed + // with old image URL it will load the image. + imageUrl = null + isLoading = true + error = null + + if (trmnlTokenDataStore.hasTokenSync()) { + Timber.d("Manually refreshing current image via WorkManager") + trmnlWorkScheduler.startOneTimeImageRefreshWork() + } else { + error = "No access token found" + isLoading = false + Timber.w("Refresh failed: No access token found") + } + } + } + TrmnlMirrorDisplayScreen.Event.ConfigureRequested -> { + navigator.goTo(AppSettingsScreen(returnToMirrorAfterSave = true)) + } + TrmnlMirrorDisplayScreen.Event.BackPressed -> { + navigator.pop() + } + TrmnlMirrorDisplayScreen.Event.ViewLogsRequested -> { + navigator.goTo(DisplayRefreshLogScreen) + } + + TrmnlMirrorDisplayScreen.Event.ToggleOverlayControls -> { + overlayControlsVisible = !overlayControlsVisible + } + + TrmnlMirrorDisplayScreen.Event.LoadNextPlaylistItemImage -> { + imageUrl = null + isLoading = true + error = null + + if (trmnlTokenDataStore.hasTokenSync()) { + Timber.d("Manually refreshing next playlist item image via WorkManager") + trmnlWorkScheduler.startOneTimeImageRefreshWork(loadNextPlaylistImage = true) + } else { + error = "No access token found" + isLoading = false + Timber.w("Refresh failed: No access token found") + } + } + } + }, + ) + } + + @CircuitInject(TrmnlMirrorDisplayScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): TrmnlMirrorDisplayPresenter + } + } + +@CircuitInject(TrmnlMirrorDisplayScreen::class, AppScope::class) +@Composable +fun TrmnlMirrorDisplayContent( + state: TrmnlMirrorDisplayScreen.State, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + // Apply fullscreen mode and keep screen on + FullScreenMode(enabled = true, keepScreenOn = true) + + Surface { + Box( + modifier = + modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // No visual indication when clicked + ) { + state.eventSink(TrmnlMirrorDisplayScreen.Event.ToggleOverlayControls) + }, + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else if (state.errorMessage != null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp), + ) { + Icon( + painter = painterResource(R.drawable.trmnl_logo_plain), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 8.dp), + ) + Text( + text = "Error: ${state.errorMessage}", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } else { + AsyncImage( + model = CoilRequestUtils.createCachedImageRequest(context, state.imageUrl), + contentDescription = "Terminal Display", + contentScale = ContentScale.Fit, + placeholder = painterResource(R.drawable.trmnl_logo_semi_transparent), + modifier = Modifier.fillMaxSize(), + ) + } + + // Floating action buttons that appear when controls are visible + AnimatedVisibility( + visible = state.overlayControlsVisible, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + ) { + OverlaySettingsView(state) + } + } + } +} + +@Composable +private fun OverlaySettingsView( + state: TrmnlMirrorDisplayScreen.State, + windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, +) { + // Shows larger button on tablets + // https://developer.android.com/develop/ui/compose/layouts/adaptive/support-different-display-sizes + // https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes + val isExpandedWidth = + windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) || + windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) + + // Choose text style based on window width + val fabTextStyle = + if (isExpandedWidth) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.bodyLarge + } + + val infoTextStyle = + if (isExpandedWidth) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + } + + Card( + modifier = + Modifier + .padding(16.dp), + elevation = + CardDefaults.cardElevation( + defaultElevation = 4.dp, + ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + ), + ) { + Column( + modifier = + Modifier + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Display Configurations", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text("Display image refresh: ${state.nextImageRefreshIn}", style = infoTextStyle) + + ExtendedFloatingActionButton( + onClick = { + state.eventSink(TrmnlMirrorDisplayScreen.Event.ConfigureRequested) + }, + icon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = if (isExpandedWidth) Modifier.size(32.dp) else Modifier, + ) + }, + text = { + Text( + "Configure TRMNL API Token", + style = fabTextStyle, + fontWeight = FontWeight.Bold, + ) + }, + ) + + ExtendedFloatingActionButton( + onClick = { + state.eventSink(TrmnlMirrorDisplayScreen.Event.RefreshCurrentPlaylistItemRequested) + }, + icon = { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = if (isExpandedWidth) Modifier.size(32.dp) else Modifier, + ) + }, + text = { + Text( + "Refresh Current Playlist Image", + style = fabTextStyle, + fontWeight = FontWeight.Bold, + ) + }, + ) + + ExtendedFloatingActionButton( + onClick = { + state.eventSink(TrmnlMirrorDisplayScreen.Event.LoadNextPlaylistItemImage) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = if (isExpandedWidth) Modifier.size(32.dp) else Modifier, + ) + }, + text = { + Text( + "Load Next Playlist Image", + style = fabTextStyle, + fontWeight = FontWeight.Bold, + ) + }, + ) + + ExtendedFloatingActionButton( + onClick = { + state.eventSink(TrmnlMirrorDisplayScreen.Event.ViewLogsRequested) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = if (isExpandedWidth) Modifier.size(32.dp) else Modifier, + ) + }, + text = { + Text( + "View Image Refresh Logs", + style = fabTextStyle, + fontWeight = FontWeight.Bold, + ) + }, + ) + } + } +} + +@Preview(name = "Trmnl Mirror Display Error Content Preview") +@Composable +fun PreviewTrmnlMirrorDisplayErrorContent() { + Surface { + TrmnlMirrorDisplayContent( + state = + TrmnlMirrorDisplayScreen.State( + imageUrl = "https://vision.hossainkhan.com/images/front-page/IMG_20180907_073851-1600x1200-camp-ahmek-bridge-bnw.jpg", + overlayControlsVisible = false, + nextImageRefreshIn = "5 minutes", + isLoading = false, + errorMessage = "Sample Error Message", + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Overlay Settings Preview") +@Composable +fun PreviewOverlaySettingsView() { + Surface { + OverlaySettingsView( + state = + TrmnlMirrorDisplayScreen.State( + imageUrl = null, + overlayControlsVisible = true, + nextImageRefreshIn = "5 minutes", + isLoading = false, + errorMessage = null, + eventSink = {}, + ), + ) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/ui/refreshlog/DisplayRefreshLogScreen.kt b/app/src/main/java/dev/hossain/trmnl/ui/refreshlog/DisplayRefreshLogScreen.kt new file mode 100644 index 0000000..2cb8955 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/refreshlog/DisplayRefreshLogScreen.kt @@ -0,0 +1,564 @@ +package dev.hossain.trmnl.ui.refreshlog + +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dev.hossain.trmnl.BuildConfig +import dev.hossain.trmnl.R +import dev.hossain.trmnl.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC +import dev.hossain.trmnl.data.log.TrmnlRefreshLog +import dev.hossain.trmnl.data.log.TrmnlRefreshLogManager +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.ui.theme.TrmnlDisplayAppTheme +import dev.hossain.trmnl.util.getTimeElapsedString +import dev.hossain.trmnl.work.RefreshWorkType +import dev.hossain.trmnl.work.TrmnlWorkScheduler +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * A screen that displays the refresh logs of the TRMNL display. + * This is meant to validate how often the refresh rate is set and when the image is updated. + * + * The screen provides functionality to: + * - View chronological logs of display refresh attempts + * - See detailed information about successful and failed refresh operations + * - Clear logs (via action button) + * - Add test logs (debug builds only) + */ +@Parcelize +data object DisplayRefreshLogScreen : Screen { + /** + * Represents the UI state for the [DisplayRefreshLogScreen]. + */ + data class State( + val logs: List, + val eventSink: (Event) -> Unit, + ) : CircuitUiState + + /** + * Events that can be triggered from the DisplayRefreshLogScreen UI. + */ + sealed class Event : CircuitUiEvent { + /** + * Event triggered when the user presses the back button. + */ + data object BackPressed : Event() + + /** + * Event triggered when the user requests to clear all logs. + */ + data object ClearLogs : Event() + + /** + * Event triggered when a test success log should be added (debug only). + */ + data object AddSuccessLog : Event() + + /** + * Event triggered when a test failure log should be added (debug only). + */ + data object AddFailLog : Event() + + /** + * Event triggered when the refresh worker should be started (debug only). + */ + data object StartRefreshWorker : Event() + } +} + +/** + * Presenter for the DisplayRefreshLogScreen. + * Manages the screen's state and handles events from the UI. + */ +class DisplayRefreshLogPresenter + @AssistedInject + constructor( + @Assisted private val navigator: Navigator, + private val activityLogManager: TrmnlRefreshLogManager, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + ) : Presenter { + /** + * Creates and returns the state for the DisplayRefreshLogScreen. + * Collects logs from the log manager and sets up event handling. + * + * @return The current UI state. + */ + @Composable + override fun present(): DisplayRefreshLogScreen.State { + val logs by activityLogManager.logsFlow.collectAsState(initial = emptyList()) + val scope = rememberCoroutineScope() + + return DisplayRefreshLogScreen.State( + logs = logs, + eventSink = { event -> + when (event) { + DisplayRefreshLogScreen.Event.BackPressed -> navigator.pop() + DisplayRefreshLogScreen.Event.ClearLogs -> { + scope.launch { + activityLogManager.clearLogs() + } + } + + DisplayRefreshLogScreen.Event.AddFailLog -> { + scope.launch { + activityLogManager.addLog( + TrmnlRefreshLog.createFailure(error = "Test failure"), + ) + } + } + DisplayRefreshLogScreen.Event.AddSuccessLog -> { + scope.launch { + activityLogManager.addLog( + TrmnlRefreshLog.createSuccess( + imageUrl = "https://debug.example.com/image.png", + imageName = "test-image.png", + refreshIntervalSeconds = 300L, + imageRefreshWorkType = RefreshWorkType.ONE_TIME.name, + ), + ) + } + } + + DisplayRefreshLogScreen.Event.StartRefreshWorker -> { + trmnlWorkScheduler.startOneTimeImageRefreshWork() + } + } + }, + ) + } + + /** + * Factory interface for creating DisplayRefreshLogPresenter instances. + */ + @CircuitInject(DisplayRefreshLogScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): DisplayRefreshLogPresenter + } + } + +/** + * Main composable function for rendering the DisplayRefreshLogScreen. + * Sets up the screen's structure including toolbar, log list, and debug controls. + */ +@CircuitInject(DisplayRefreshLogScreen::class, AppScope::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DisplayRefreshLogContent( + state: DisplayRefreshLogScreen.State, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Display Refresh Logs") }, + navigationIcon = { + IconButton(onClick = { state.eventSink(DisplayRefreshLogScreen.Event.BackPressed) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { state.eventSink(DisplayRefreshLogScreen.Event.ClearLogs) }) { + Icon(Icons.Default.Clear, contentDescription = "Clear logs") + } + }, + ) + }, + bottomBar = { + // Debug controls only visible in debug builds + if (BuildConfig.DEBUG) { + DebugControls( + onAddSuccessLog = { + state.eventSink(DisplayRefreshLogScreen.Event.AddSuccessLog) + }, + onAddFailLog = { + state.eventSink(DisplayRefreshLogScreen.Event.AddFailLog) + }, + onStartRefreshWorker = { + state.eventSink(DisplayRefreshLogScreen.Event.StartRefreshWorker) + }, + // Use a modifier that takes navigation bar padding into account + modifier = Modifier.navigationBarsPadding(), + ) + } + }, + // Use WindowInsets.navigationBars to ensure content doesn't overlap with the navigation bar + contentWindowInsets = WindowInsets.navigationBars, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + if (state.logs.isEmpty()) { + Text( + text = "No logs available", + modifier = Modifier.align(Alignment.Center), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(state.logs) { log -> + LogItemView(log = log) + } + } + } + } + } +} + +/** + * Composable function that renders a single log entry as a card. + * Displays different information based on whether the log represents a success or failure. + */ +@Composable +private fun LogItemView( + log: TrmnlRefreshLog, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + // Format timestamp + val dateFormat = SimpleDateFormat("MMM dd, yyyy hh:mm:ss a", Locale.getDefault()) + val formattedDate = dateFormat.format(Date(log.timestamp)) + + // Calculate time elapsed + val timeElapsed = getTimeElapsedString(log.timestamp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = formattedDate, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = timeElapsed, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (log.success) Icons.Default.CheckCircle else Icons.Default.Clear, + contentDescription = if (log.success) "Success" else "Failed", + tint = if (log.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + text = if (log.success) "Success" else "Failed", + style = MaterialTheme.typography.bodyLarge, + color = if (log.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (log.success) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp), + ) { + Icon( + painter = painterResource(R.drawable.image_24dp), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + + Text( + text = "Image File Name:", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + ) + } + Text( + text = log.imageName ?: "N/A", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (log.refreshIntervalSeconds != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp), + ) { + Icon( + painter = painterResource(R.drawable.timer_24dp), + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + + Text( + text = + "API Refresh Rate: " + + when { + log.refreshIntervalSeconds > 60 -> "${log.refreshIntervalSeconds / 60} minutes" + else -> "${log.refreshIntervalSeconds} seconds" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (log.imageRefreshWorkType != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp), + ) { + Icon( + painter = + when (log.imageRefreshWorkType) { + RefreshWorkType.ONE_TIME.name -> painterResource(R.drawable.counter_1_24dp) + RefreshWorkType.PERIODIC.name -> painterResource(R.drawable.time_auto_24dp) + else -> painterResource(R.drawable.question_mark_24dp) + }, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + + Text( + text = + when (log.imageRefreshWorkType) { + RefreshWorkType.ONE_TIME.name -> "Refresh Type: Manual One-time Refresh" + RefreshWorkType.PERIODIC.name -> "Refresh Type: Automatic Scheduled Refresh" + else -> "Unknown (${log.imageRefreshWorkType})" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } else { + Text( + text = "Error:", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = log.error ?: "Unknown error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * Debug controls for manually adding test logs. Only visible in debug builds. + */ +@Composable +private fun DebugControls( + onAddSuccessLog: () -> Unit, + onAddFailLog: () -> Unit, + onStartRefreshWorker: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(16.dp), + ) { + Text( + text = "Debug Controls (for testing)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = onAddSuccessLog, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) { + Text("Add Success Log") + } + + Button( + onClick = onAddFailLog, + modifier = Modifier.weight(1f).padding(start = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Add Fail Log") + } + } + + Button( + onClick = onStartRefreshWorker, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + ), + ) { + Text("Start Refresh Worker") + } + } +} + +@Preview(name = "Display Refresh Log Content - Empty") +@Composable +private fun PreviewDisplayRefreshLogContentEmpty() { + TrmnlDisplayAppTheme { + DisplayRefreshLogContent( + state = + DisplayRefreshLogScreen.State( + logs = emptyList(), + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Display Refresh Log Content - With Logs") +@Composable +private fun PreviewDisplayRefreshLogContentWithLogs() { + val sampleLogs = + listOf( + TrmnlRefreshLog( + timestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5), + success = true, + imageUrl = "https://example.com/image1.png", + imageName = "preview-image.bmp", + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SEC, + imageRefreshWorkType = RefreshWorkType.PERIODIC.name, + error = null, + ), + TrmnlRefreshLog( + timestamp = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1), + success = false, + imageUrl = null, + imageName = "preview-image.bmp", + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SEC, + imageRefreshWorkType = RefreshWorkType.ONE_TIME.name, + error = "Failed to connect to server (HTTP 500)", + ), + TrmnlRefreshLog( + timestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1), + success = true, + imageUrl = "https://example.com/image2.png", + imageName = "preview-image.bmp", + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SEC, + imageRefreshWorkType = RefreshWorkType.PERIODIC.name, + error = null, + ), + ) + TrmnlDisplayAppTheme { + DisplayRefreshLogContent( + state = + DisplayRefreshLogScreen.State( + logs = sampleLogs, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Log Item View - Success") +@Composable +private fun PreviewLogItemViewSuccess() { + val log = + TrmnlRefreshLog( + timestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(10), + success = true, + imageUrl = "https://images.unsplash.com/photo-1617591897383-14876a3e6d1a", + imageName = "preview-image.bmp", + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SEC, + imageRefreshWorkType = RefreshWorkType.PERIODIC.name, + error = null, + ) + TrmnlDisplayAppTheme { + LogItemView(log = log) + } +} + +@Preview(name = "Log Item View - Failure") +@Composable +private fun PreviewLogItemViewFailure() { + val log = + TrmnlRefreshLog( + timestamp = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(30), + success = false, + imageUrl = null, + imageName = "preview-image.bmp", + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SEC, + imageRefreshWorkType = RefreshWorkType.ONE_TIME.name, + error = "Network timeout while fetching image.", + ) + TrmnlDisplayAppTheme { + LogItemView(log = log) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/ui/settings/AppSettingsScreen.kt b/app/src/main/java/dev/hossain/trmnl/ui/settings/AppSettingsScreen.kt new file mode 100644 index 0000000..fa524c0 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/settings/AppSettingsScreen.kt @@ -0,0 +1,878 @@ +package dev.hossain.trmnl.ui.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.work.WorkInfo +import coil3.compose.AsyncImage +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dev.hossain.trmnl.R +import dev.hossain.trmnl.data.AppConfig.DEFAULT_REFRESH_INTERVAL_SEC +import dev.hossain.trmnl.data.DevConfig +import dev.hossain.trmnl.data.TrmnlDisplayRepository +import dev.hossain.trmnl.data.TrmnlTokenDataStore +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.ui.display.TrmnlMirrorDisplayScreen +import dev.hossain.trmnl.ui.settings.AppSettingsScreen.ValidationResult +import dev.hossain.trmnl.ui.settings.AppSettingsScreen.ValidationResult.Failure +import dev.hossain.trmnl.ui.settings.AppSettingsScreen.ValidationResult.Success +import dev.hossain.trmnl.ui.theme.TrmnlDisplayAppTheme +import dev.hossain.trmnl.util.CoilRequestUtils +import dev.hossain.trmnl.util.NextImageRefreshDisplayInfo +import dev.hossain.trmnl.util.isHttpError +import dev.hossain.trmnl.util.nextRunTime +import dev.hossain.trmnl.util.toColor +import dev.hossain.trmnl.util.toDisplayString +import dev.hossain.trmnl.util.toIcon +import dev.hossain.trmnl.work.TrmnlImageUpdateManager +import dev.hossain.trmnl.work.TrmnlWorkScheduler +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Screen for configuring the TRMNL mirror app settings. + * + * This screen allows users to set up and validate their TRMNL access token, + * which is required to connect to the TRMNL API service. It displays validation + * results, image previews when successful, and provides options to save the + * configuration which in turn schedules refresh job using [TrmnlWorkScheduler]. + * + * The screen can be configured to either return to the mirror display after + * saving or to pop back to the previous screen. + */ +@Parcelize +data class AppSettingsScreen( + val returnToMirrorAfterSave: Boolean = false, +) : Screen { + data class State( + val accessToken: String, + val isLoading: Boolean = false, + val validationResult: ValidationResult? = null, + val nextRefreshJobInfo: NextImageRefreshDisplayInfo? = null, + val eventSink: (Event) -> Unit, + ) : CircuitUiState + + sealed class ValidationResult { + data class Success( + val imageUrl: String, + val refreshRateSecs: Long, + ) : ValidationResult() + + data class Failure( + val message: String, + ) : ValidationResult() + } + + /** + * Events that can be triggered from the AppSettingsScreen UI. + */ + sealed class Event : CircuitUiEvent { + /** + * Event triggered when the access token is changed. + */ + data class AccessTokenChanged( + val token: String, + ) : Event() + + /** + * Event triggered to validate the current access token. + */ + data object ValidateToken : Event() + + /** + * Event triggered to save the settings and continue to the next screen. + */ + data object SaveAndContinue : Event() + + /** + * Event triggered when the back button is pressed. + */ + data object BackPressed : Event() + + /** + * Event triggered to cancel the scheduled work. + */ + data object CancelScheduledWork : Event() + } +} + +/** + * Presenter for the [AppSettingsScreen]. + * Manages the screen's state and handles events from the UI. + */ +class AppSettingsPresenter + @AssistedInject + constructor( + @Assisted private val navigator: Navigator, + @Assisted private val screen: AppSettingsScreen, + private val displayRepository: TrmnlDisplayRepository, + private val trmnlTokenDataStore: TrmnlTokenDataStore, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, + ) : Presenter { + @Composable + override fun present(): AppSettingsScreen.State { + var accessToken by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var validationResult by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + val nextRefreshInfo by produceState(null) { + trmnlWorkScheduler.getScheduledWorkInfo().collect { workInfo -> + value = workInfo?.nextRunTime() + } + } + + // Load saved token if available + LaunchedEffect(Unit) { + trmnlTokenDataStore.accessTokenFlow.collect { savedToken -> + if (!savedToken.isNullOrBlank()) { + accessToken = savedToken + } + } + } + + return AppSettingsScreen.State( + accessToken = accessToken, + isLoading = isLoading, + validationResult = validationResult, + nextRefreshJobInfo = nextRefreshInfo, + eventSink = { event -> + when (event) { + is AppSettingsScreen.Event.AccessTokenChanged -> { + accessToken = event.token + // Clear previous validation when token changes + validationResult = null + } + + AppSettingsScreen.Event.ValidateToken -> { + scope.launch { + focusManager.clearFocus() + isLoading = true + validationResult = null + + val response = displayRepository.getCurrentDisplayData(accessToken) + + if (response.status.isHttpError()) { + // Handle explicit error response + val errorMessage = response.error ?: "Device not found" + validationResult = Failure(errorMessage) + } else if (response.imageUrl.isNotBlank()) { + // Success case - we have an image URL + trmnlImageUpdateManager.updateImage(response.imageUrl, response.refreshIntervalSeconds) + validationResult = + Success( + response.imageUrl, + response.refreshIntervalSeconds ?: DEFAULT_REFRESH_INTERVAL_SEC, + ) + } else { + // No error but also no image URL + val errorMessage = response.error ?: "" + validationResult = Failure("$errorMessage No image URL received.") + } + isLoading = false + } + } + + AppSettingsScreen.Event.SaveAndContinue -> { + // Only save if validation was successful + val result = validationResult + if (result is ValidationResult.Success) { + scope.launch { + trmnlTokenDataStore.saveAccessToken(accessToken) + trmnlWorkScheduler.updateRefreshInterval(result.refreshRateSecs) + + if (screen.returnToMirrorAfterSave) { + navigator.goTo(TrmnlMirrorDisplayScreen) + } else { + navigator.pop() + } + } + } + } + + AppSettingsScreen.Event.BackPressed -> { + navigator.pop() + } + + AppSettingsScreen.Event.CancelScheduledWork -> { + trmnlWorkScheduler.cancelImageRefreshWork() + } + } + }, + ) + } + + @CircuitInject(AppSettingsScreen::class, AppScope::class) + @AssistedFactory + fun interface Factory { + fun create( + navigator: Navigator, + screen: AppSettingsScreen, + ): AppSettingsPresenter + } + } + +/** + * Main Composable function for rendering the AppSettingsScreen. + * Sets up the screen's structure including form, validation result display, and work schedule status. + */ +@CircuitInject(AppSettingsScreen::class, AppScope::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSettingsContent( + state: AppSettingsScreen.State, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + val scrollState = rememberScrollState() + val hasToken = state.accessToken.isNotBlank() + val trmnlWorkScheduler = remember { TrmnlWorkScheduler(context, TrmnlTokenDataStore(context)) } + + // Control password visibility + var passwordVisible by remember { mutableStateOf(false) } + + // Create masked version of the token for display + val maskedToken = + with(state.accessToken) { + if (length > 4) { + "${take(2)}${"*".repeat(length - 4)}${takeLast(2)}" + } else { + this // Don't mask if token is too short + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("TRMNL Configuration") }, + navigationIcon = { + // Only show the back button if a token is already set + if (hasToken) { + IconButton(onClick = { state.eventSink(AppSettingsScreen.Event.BackPressed) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(innerPadding) + .padding(horizontal = 32.dp) + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (DevConfig.FAKE_API_RESPONSE) { + // Show fake API banner when DevConfig.FAKE_API_RESPONSE is `true` + FakeApiInfoBanner( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) + } + + Icon( + painter = painterResource(R.drawable.trmnl_logo_plain), + contentDescription = "TRMNL Logo", + tint = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + ) + + Text( + text = "Terminal API Configuration", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Password field with toggle visibility button + OutlinedTextField( + value = state.accessToken, + onValueChange = { state.eventSink(AppSettingsScreen.Event.AccessTokenChanged(it)) }, + label = { Text("Access Token") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + state.eventSink(AppSettingsScreen.Event.ValidateToken) + }, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + painter = painterResource(if (passwordVisible) R.drawable.visibility_off_24dp else R.drawable.visibility_24dp), + contentDescription = if (passwordVisible) "Hide password" else "Show password", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TokenInfoTextView() + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { state.eventSink(AppSettingsScreen.Event.ValidateToken) }, + enabled = state.accessToken.isNotBlank() && !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Validate Token") + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Show validation result + state.validationResult?.let { result -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + when (result) { + is ValidationResult.Success -> { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "✅ Token Valid", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + + Text( + "Token: $maskedToken", + style = MaterialTheme.typography.bodyMedium, + ) + + // Image preview using Coil with improved caching + AsyncImage( + model = CoilRequestUtils.createCachedImageRequest(context, result.imageUrl), + contentDescription = "Preview image", + contentScale = ContentScale.Fit, + modifier = + Modifier + .size(240.dp) + .padding(4.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { state.eventSink(AppSettingsScreen.Event.SaveAndContinue) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save and Continue") + } + } + } + is ValidationResult.Failure -> { + // Error state remains the same + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "❌ Validation Failed", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + result.message, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + + if (state.isLoading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + + Spacer(modifier = Modifier.height(24.dp)) + WorkScheduleStatusCard(state = state, modifier = Modifier.fillMaxWidth()) + } + } +} + +/** + * Displays the status of the TRMNL display image refresh schedule. + * + * Shows details about the next scheduled refresh, its status, and provides an option + * to cancel the scheduled work if applicable. + */ +@Composable +private fun WorkScheduleStatusCard( + state: AppSettingsScreen.State, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.Start, + ) { + Text( + text = "TRMNL Display Image Refresh Schedule Status", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + val nextRefreshJobInfo: NextImageRefreshDisplayInfo? = state.nextRefreshJobInfo + if (nextRefreshJobInfo != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + imageVector = nextRefreshJobInfo.workerState.toIcon(), + contentDescription = null, + tint = nextRefreshJobInfo.workerState.toColor(), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Status: ${nextRefreshJobInfo.workerState.toDisplayString()}", + style = MaterialTheme.typography.bodyMedium, + color = nextRefreshJobInfo.workerState.toColor(), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Default.DateRange, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Next refresh: ${nextRefreshJobInfo.timeUntilNextRefresh}", + style = MaterialTheme.typography.bodyMedium, + ) + } + + Text( + text = "Scheduled for: ${nextRefreshJobInfo.nextRefreshOnDateTime}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 28.dp, top = 2.dp), + ) + + // Add a button to cancel the work if it's scheduled + if (nextRefreshJobInfo.workerState == WorkInfo.State.ENQUEUED || + nextRefreshJobInfo.workerState == WorkInfo.State.RUNNING || + nextRefreshJobInfo.workerState == WorkInfo.State.BLOCKED + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + state.eventSink(AppSettingsScreen.Event.CancelScheduledWork) + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Cancel scheduled work", + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Cancel Periodic Refresh Job") + } + } + } else { + Text( + text = "No scheduled refresh work found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * A composable function that displays a banner indicating that the app is in developer mode + * and is using mock data instead of real API calls. + */ +@Composable +private fun FakeApiInfoBanner(modifier: Modifier = Modifier) { + Card( + modifier = modifier, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + ) + + Column { + Text( + text = "Developer Mode - Using Fake API", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "This app is currently using mock data instead of real API calls.", + style = MaterialTheme.typography.bodySmall, + ) + + Text( + text = "Set `DevConfig.FAKE_API_RESPONSE` to `false` to use real API.", + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + ) + } + } + } +} + +@Composable +private fun TokenInfoTextView() { + // Informational text with links using withLink + val uriHandler = LocalUriHandler.current + val linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline) + val annotatedString = + buildAnnotatedString { + append("Your TRMNL device token can be found in settings screen from your ") + + withLink( + LinkAnnotation.Url( + url = "https://usetrmnl.com/dashboard", + styles = TextLinkStyles(style = linkStyle), + linkInteractionListener = { uriHandler.openUri("https://usetrmnl.com/dashboard") }, + ), + ) { + withStyle(style = linkStyle) { + append("dashboard") + } + } + + append(". ") + + withLink( + LinkAnnotation.Url( + url = "https://docs.usetrmnl.com/go/private-api/introduction", + styles = TextLinkStyles(style = linkStyle), + linkInteractionListener = { uriHandler.openUri("https://docs.usetrmnl.com/go/private-api/introduction") }, + ), + ) { + withStyle(style = linkStyle) { + append("Learn more") + } + } + + append(".") + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = "Information", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = annotatedString, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + } +} + +@Preview(name = "App Settings Content - Initial State") +@Composable +private fun PreviewAppSettingsContentInitial() { + TrmnlDisplayAppTheme { + AppSettingsContent( + state = + AppSettingsScreen.State( + accessToken = "", + isLoading = false, + validationResult = null, + nextRefreshJobInfo = null, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "App Settings Content - Loading State") +@Composable +private fun PreviewAppSettingsContentLoading() { + TrmnlDisplayAppTheme { + AppSettingsContent( + state = + AppSettingsScreen.State( + accessToken = "some-token", + isLoading = true, + validationResult = null, + nextRefreshJobInfo = null, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "App Settings Content - Validation Success") +@Composable +private fun PreviewAppSettingsContentSuccess() { + TrmnlDisplayAppTheme { + AppSettingsContent( + state = + AppSettingsScreen.State( + accessToken = "valid-token-123", + isLoading = false, + validationResult = + ValidationResult.Success( + imageUrl = "https://example.com/image.png", // Placeholder URL + refreshRateSecs = 3600, + ), + nextRefreshJobInfo = null, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "App Settings Content - Validation Failure") +@Composable +private fun PreviewAppSettingsContentFailure() { + TrmnlDisplayAppTheme { + AppSettingsContent( + state = + AppSettingsScreen.State( + accessToken = "invalid-token", + isLoading = false, + validationResult = + ValidationResult.Failure( + message = "Invalid access token provided. Please check and try again.", + ), + nextRefreshJobInfo = null, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "App Settings Content - With Scheduled Work") +@Composable +private fun PreviewAppSettingsContentWithWork() { + val formatter = DateTimeFormatter.ofPattern("MMM dd 'at' hh:mm:ss a") + val nextRunTimeMillis = Instant.now().plusSeconds(15 * 60).toEpochMilli() + val nextRunTimeFormatted = Instant.ofEpochMilli(nextRunTimeMillis).atZone(ZoneId.systemDefault()).format(formatter) + + TrmnlDisplayAppTheme { + AppSettingsContent( + state = + AppSettingsScreen.State( + accessToken = "valid-token-123", + isLoading = false, + validationResult = null, // Can also be Success state + nextRefreshJobInfo = + NextImageRefreshDisplayInfo( + workerState = WorkInfo.State.ENQUEUED, + timeUntilNextRefresh = "in 15 minutes", + nextRefreshOnDateTime = nextRunTimeFormatted, + nextRefreshTimeMillis = nextRunTimeMillis, + ), + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Work Schedule Status Card - Scheduled") +@Composable +private fun PreviewWorkScheduleStatusCardScheduled() { + val formatter = DateTimeFormatter.ofPattern("MMM dd 'at' hh:mm:ss a") + val nextRunTimeMillis = Instant.now().plusSeconds(15 * 60).toEpochMilli() + val nextRunTimeFormatted = Instant.ofEpochMilli(nextRunTimeMillis).atZone(ZoneId.systemDefault()).format(formatter) + + TrmnlDisplayAppTheme { + WorkScheduleStatusCard( + state = + AppSettingsScreen.State( + accessToken = "some-token", + nextRefreshJobInfo = + NextImageRefreshDisplayInfo( + workerState = WorkInfo.State.ENQUEUED, + timeUntilNextRefresh = "in 15 minutes", + nextRefreshOnDateTime = nextRunTimeFormatted, + nextRefreshTimeMillis = nextRunTimeMillis, + ), + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Work Schedule Status Card - No Work") +@Composable +private fun PreviewWorkScheduleStatusCardNoWork() { + TrmnlDisplayAppTheme { + WorkScheduleStatusCard( + state = + AppSettingsScreen.State( + accessToken = "some-token", + nextRefreshJobInfo = null, + eventSink = {}, + ), + ) + } +} + +@Preview(name = "Fake API Info Banner") +@Composable +private fun PreviewFakeApiInfoBanner() { + TrmnlDisplayAppTheme { + FakeApiInfoBanner() + } +} + +@Preview(name = "Info Text View Preview", showBackground = true) +@Composable +private fun PreviewInfoTextView() { + TrmnlDisplayAppTheme { + TokenInfoTextView() + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/ui/theme/Color.kt b/app/src/main/java/dev/hossain/trmnl/ui/theme/Color.kt new file mode 100644 index 0000000..46b5496 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/theme/Color.kt @@ -0,0 +1,16 @@ +package dev.hossain.trmnl.ui.theme + +import androidx.compose.ui.graphics.Color + +// Material Color +// https://m3.material.io/styles/color/system/overview + +// Light Theme +val Blue80 = Color(0xFF82B1FF) +val BlueGrey80 = Color(0xFFB0BEC5) +val Grey80 = Color(0xFFECEFF1) + +// Dark Theme +val Blue40 = Color(0xFF2236B0) +val BlueGrey40 = Color(0xFF546E7A) +val Grey40 = Color(0xFF47676F) diff --git a/app/src/main/java/dev/hossain/trmnl/ui/theme/Theme.kt b/app/src/main/java/dev/hossain/trmnl/ui/theme/Theme.kt new file mode 100644 index 0000000..2bdc126 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package dev.hossain.trmnl.ui.theme +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = + darkColorScheme( + primary = Blue80, + secondary = BlueGrey80, + tertiary = Grey80, + ) + +private val LightColorScheme = + lightColorScheme( + primary = Blue40, + secondary = BlueGrey40, + tertiary = Grey40, + ) + +@Composable +fun TrmnlDisplayAppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content, + ) +} diff --git a/app/src/main/java/dev/hossain/trmnl/ui/theme/Type.kt b/app/src/main/java/dev/hossain/trmnl/ui/theme/Type.kt new file mode 100644 index 0000000..6ebc2ae --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/ui/theme/Type.kt @@ -0,0 +1,52 @@ +package dev.hossain.trmnl.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import dev.hossain.trmnl.R + +val provider = + GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, + ) + +val bodyFontFamily = + FontFamily( + Font( + googleFont = GoogleFont("EB Garamond"), + fontProvider = provider, + ), + ) + +val displayFontFamily = + FontFamily( + Font( + googleFont = GoogleFont("EB Garamond"), + fontProvider = provider, + ), + ) + +// Default Material 3 typography values +val baseline = Typography() + +val AppTypography = + Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = displayFontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = displayFontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = displayFontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = displayFontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = displayFontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = displayFontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = displayFontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = displayFontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = displayFontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = bodyFontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = bodyFontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = bodyFontFamily), + ) diff --git a/app/src/main/java/dev/hossain/trmnl/util/CoilRequestUtils.kt b/app/src/main/java/dev/hossain/trmnl/util/CoilRequestUtils.kt new file mode 100644 index 0000000..88251f5 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/util/CoilRequestUtils.kt @@ -0,0 +1,36 @@ +package dev.hossain.trmnl.util + +import android.content.Context +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.crossfade + +/** + * Utility functions for Coil image loading. + */ +object CoilRequestUtils { + /** + * Creates an ImageRequest with optimal caching settings. + * + * @param context The Android context + * @param url The image URL to load + * @param enableCrossfade Whether to enable crossfade animation (default: true) + * @return Configured ImageRequest + */ + fun createCachedImageRequest( + context: Context, + url: String?, + enableCrossfade: Boolean = true, + ): ImageRequest = + ImageRequest + .Builder(context) + .data(url) + .apply { + if (enableCrossfade) { + crossfade(true) + } + }.memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .build() +} diff --git a/app/src/main/java/dev/hossain/trmnl/util/DateTimeFormatter.kt b/app/src/main/java/dev/hossain/trmnl/util/DateTimeFormatter.kt new file mode 100644 index 0000000..0fbc7ed --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/util/DateTimeFormatter.kt @@ -0,0 +1,42 @@ +package dev.hossain.trmnl.util + +/** + * Returns a human-readable string representing time elapsed since the timestamp. + * Examples: "9 minutes ago", "3 hours and 23 minutes ago", "2 days 12 hours and 45 minutes ago" + */ +internal fun getTimeElapsedString(timestamp: Long): String { + val now = System.currentTimeMillis() + val diffMillis = now - timestamp + + // Convert to different units + val seconds = diffMillis / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + val days = hours / 24 + + return when { + days > 0 -> { + val remainingHours = hours % 24 + val remainingMinutes = minutes % 60 + + when { + remainingHours > 0 && remainingMinutes > 0 -> + "$days ${if (days == 1L) "day" else "days"} $remainingHours ${if (remainingHours == 1L) "hour" else "hours"} and $remainingMinutes ${if (remainingMinutes == 1L) "minute" else "minutes"} ago" + remainingHours > 0 -> + "$days ${if (days == 1L) "day" else "days"} and $remainingHours ${if (remainingHours == 1L) "hour" else "hours"} ago" + else -> + "$days ${if (days == 1L) "day" else "days"} ago" + } + } + hours > 0 -> { + val remainingMinutes = minutes % 60 + if (remainingMinutes > 0) { + "$hours ${if (hours == 1L) "hour" else "hours"} and $remainingMinutes ${if (remainingMinutes == 1L) "minute" else "minutes"} ago" + } else { + "$hours ${if (hours == 1L) "hour" else "hours"} ago" + } + } + minutes > 0 -> "$minutes ${if (minutes == 1L) "minute" else "minutes"} ago" + else -> "Just now" + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/util/NetworkExtensions.kt b/app/src/main/java/dev/hossain/trmnl/util/NetworkExtensions.kt new file mode 100644 index 0000000..c575d1b --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/util/NetworkExtensions.kt @@ -0,0 +1,27 @@ +package dev.hossain.trmnl.util + +/** + * 500 Internal Server Error - A generic error message, given when an unexpected + * condition was encountered and no more specific message is suitable. + */ +internal const val HTTP_500 = 500 + +/** + * 200 OK - The request has succeeded. + */ +internal const val HTTP_200 = 200 + +/** + * TRMNL server currently returns `0` for success for some APIs. + */ +internal const val HTTP_OK = 0 + +/** + * Extension function to check if the HTTP status code is OK based on TRMNL server responses. + */ +internal fun Int?.isHttpOk(): Boolean = this == HTTP_OK || this == HTTP_200 + +/** + * Extension function to check if the HTTP status code is an error. + */ +internal fun Int?.isHttpError(): Boolean = this == HTTP_500 || this == null diff --git a/app/src/main/java/dev/hossain/trmnl/util/WorkInfoExtensions.kt b/app/src/main/java/dev/hossain/trmnl/util/WorkInfoExtensions.kt new file mode 100644 index 0000000..04550d0 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/util/WorkInfoExtensions.kt @@ -0,0 +1,89 @@ +package dev.hossain.trmnl.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.work.WorkInfo +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Represents the display information for the next image refresh. + * @see [WorkInfo.nextRunTime] + */ +data class NextImageRefreshDisplayInfo( + val workerState: WorkInfo.State, + val timeUntilNextRefresh: String, + val nextRefreshOnDateTime: String, + val nextRefreshTimeMillis: Long, +) + +fun WorkInfo.State?.toDisplayString(): String = + when (this) { + WorkInfo.State.ENQUEUED -> "Scheduled" + WorkInfo.State.RUNNING -> "Running now" + WorkInfo.State.SUCCEEDED -> "Completed successfully" + WorkInfo.State.FAILED -> "Failed" + WorkInfo.State.BLOCKED -> "Waiting for conditions" + WorkInfo.State.CANCELLED -> "Cancelled\nValidate and continue to reschedule" + null -> "Unknown" + } + +@Composable +fun WorkInfo.State?.toColor(): Color = + when (this) { + WorkInfo.State.ENQUEUED -> MaterialTheme.colorScheme.primary + WorkInfo.State.RUNNING -> MaterialTheme.colorScheme.tertiary + WorkInfo.State.SUCCEEDED -> MaterialTheme.colorScheme.primary + WorkInfo.State.FAILED -> MaterialTheme.colorScheme.error + WorkInfo.State.BLOCKED -> MaterialTheme.colorScheme.secondary + WorkInfo.State.CANCELLED -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurface + } + +fun WorkInfo.State?.toIcon(): ImageVector = + when (this) { + WorkInfo.State.ENQUEUED -> Icons.Default.Refresh + WorkInfo.State.RUNNING -> Icons.Default.PlayArrow + WorkInfo.State.SUCCEEDED -> Icons.Default.CheckCircle + WorkInfo.State.FAILED -> Icons.Default.Warning + WorkInfo.State.BLOCKED -> Icons.Default.Clear + WorkInfo.State.CANCELLED -> Icons.Default.Clear + null -> Icons.Default.Refresh + } + +fun WorkInfo?.nextRunTime(): NextImageRefreshDisplayInfo? { + if (this == null) { + return null + } + + val formatter = DateTimeFormatter.ofPattern("MMM dd 'at' hh:mm:ss a") + val nextRunTime: String = + Instant + .ofEpochMilli(nextScheduleTimeMillis) + .atZone(ZoneId.systemDefault()) + .format(formatter) + + val timeUntil = nextScheduleTimeMillis - Instant.now().toEpochMilli() + val timeUntilText = + when { + timeUntil <= 0 -> "any moment now" + timeUntil < 60000 -> "in ${timeUntil / 1000} seconds" + timeUntil < 3600000 -> "in ${timeUntil / 60000} minutes" + else -> "in ${timeUntil / 3600000} hours" + } + return NextImageRefreshDisplayInfo( + workerState = state, + timeUntilNextRefresh = timeUntilText, + nextRefreshOnDateTime = nextRunTime, + nextRefreshTimeMillis = nextScheduleTimeMillis, + ) +} diff --git a/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkResult.kt b/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkResult.kt new file mode 100644 index 0000000..26070c6 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkResult.kt @@ -0,0 +1,12 @@ +package dev.hossain.trmnl.work + +import androidx.annotation.Keep + +/** + * Enum class to represent the result of the refresh work by the [TrmnlImageRefreshWorker]. + */ +@Keep +enum class RefreshWorkResult { + SUCCESS, + FAILURE, +} diff --git a/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkType.kt b/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkType.kt new file mode 100644 index 0000000..f37389f --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/RefreshWorkType.kt @@ -0,0 +1,21 @@ +package dev.hossain.trmnl.work + +import androidx.annotation.Keep +import dev.hossain.trmnl.data.TrmnlDisplayInfo + +/** + * Enum class to represent the type of refresh work. + */ +@Keep +enum class RefreshWorkType { + /** + * Onetime worker request is made by user manually at any time. + */ + ONE_TIME, + + /** + * Periodic worker request scheduled by user and time is provided by API server. + * @see TrmnlDisplayInfo.refreshIntervalSeconds + */ + PERIODIC, +} diff --git a/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageRefreshWorker.kt b/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageRefreshWorker.kt new file mode 100644 index 0000000..5cc29ed --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageRefreshWorker.kt @@ -0,0 +1,184 @@ +package dev.hossain.trmnl.work + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dev.hossain.trmnl.MainActivity +import dev.hossain.trmnl.data.TrmnlDisplayRepository +import dev.hossain.trmnl.data.TrmnlTokenDataStore +import dev.hossain.trmnl.data.log.TrmnlRefreshLogManager +import dev.hossain.trmnl.di.WorkerModule +import dev.hossain.trmnl.ui.display.TrmnlMirrorDisplayScreen +import dev.hossain.trmnl.util.isHttpError +import dev.hossain.trmnl.util.isHttpOk +import dev.hossain.trmnl.work.RefreshWorkResult.FAILURE +import dev.hossain.trmnl.work.RefreshWorkResult.SUCCESS +import dev.hossain.trmnl.work.TrmnlWorkScheduler.Companion.IMAGE_REFRESH_PERIODIC_WORK_TAG +import kotlinx.coroutines.flow.firstOrNull +import timber.log.Timber +import javax.inject.Inject + +/** + * Worker to refresh the image displayed on the TRMNL mirror display. + * + * The worker result is observed in [MainActivity] and then updated via the [TrmnlImageUpdateManager]. + * Whenever the image is updated, the [TrmnlImageUpdateManager] will notify the observers. + * In this case the [TrmnlMirrorDisplayScreen] will recompose and update the image. + * + * @see TrmnlImageUpdateManager + * @see TrmnlMirrorDisplayScreen + */ +class TrmnlImageRefreshWorker( + appContext: Context, + params: WorkerParameters, + private val displayRepository: TrmnlDisplayRepository, + private val trmnlTokenDataStore: TrmnlTokenDataStore, + private val refreshLogManager: TrmnlRefreshLogManager, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, +) : CoroutineWorker(appContext, params) { + companion object { + private const val TAG = "TrmnlWorker" + + const val KEY_REFRESH_RESULT = "refresh_result" + const val KEY_NEW_IMAGE_URL = "new_image_url" + const val KEY_ERROR_MESSAGE = "error_message" + const val PARAM_REFRESH_WORK_TYPE = "refresh_work_type" + const val PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE = "load_next_playlist_image" + } + + override suspend fun doWork(): Result { + Timber.tag(TAG).d("Starting image refresh work ($tags)") + + // Get the work type from the input data + val workTypeValue = inputData.getString(PARAM_REFRESH_WORK_TYPE) ?: RefreshWorkType.ONE_TIME.name + val loadNextPluginImage = inputData.getBoolean(PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE, false) + + Timber.tag(TAG).d("Work type: $workTypeValue, loadNextPluginImage: $loadNextPluginImage") + + // Get current token + val token = trmnlTokenDataStore.accessTokenFlow.firstOrNull() + + if (token.isNullOrBlank()) { + Timber.tag(TAG).w("Token is not set, skipping image refresh") + refreshLogManager.addFailureLog("No access token found") + return Result.failure( + workDataOf( + KEY_REFRESH_RESULT to FAILURE.name, + KEY_ERROR_MESSAGE to "No access token found", + ), + ) + } + + // Fetch TRMNL display image - current or next from playlist based on request type + val trmnlDisplayInfo = + if (loadNextPluginImage) { + displayRepository.getNextDisplayData(token) + } else { + displayRepository.getCurrentDisplayData(token) + } + + // Check for errors + if (trmnlDisplayInfo.status.isHttpError()) { + Timber.tag(TAG).w("Failed to fetch display data: ${trmnlDisplayInfo.error}") + refreshLogManager.addFailureLog(trmnlDisplayInfo.error ?: "Unknown server error") + return Result.failure( + workDataOf( + KEY_REFRESH_RESULT to FAILURE.name, + KEY_ERROR_MESSAGE to (trmnlDisplayInfo.error ?: "Unknown server error"), + ), + ) + } + + // Check if image URL is valid + if (trmnlDisplayInfo.imageUrl.isEmpty() || trmnlDisplayInfo.status.isHttpOk().not()) { + Timber.tag(TAG).w("No image URL provided in response. ${trmnlDisplayInfo.error}") + refreshLogManager.addFailureLog("No image URL provided in response. ${trmnlDisplayInfo.error}") + return Result.failure( + workDataOf( + KEY_REFRESH_RESULT to FAILURE.name, + KEY_ERROR_MESSAGE to "No image URL provided in response. ${trmnlDisplayInfo.error}", + ), + ) + } + + // ✅ Log success and update image + refreshLogManager.addSuccessLog( + imageUrl = trmnlDisplayInfo.imageUrl, + imageName = trmnlDisplayInfo.imageName, + refreshIntervalSeconds = trmnlDisplayInfo.refreshIntervalSeconds, + imageRefreshWorkType = workTypeValue, + ) + + // Check if we should adapt refresh rate + val refreshRate = trmnlDisplayInfo.refreshIntervalSeconds + refreshRate?.let { newRefreshRateSec -> + if (trmnlTokenDataStore.shouldUpdateRefreshRate(newRefreshRateSec)) { + Timber.tag(TAG).d("Refresh rate changed, updating periodic work and saving new rate") + trmnlTokenDataStore.saveRefreshRateSeconds(newRefreshRateSec) + trmnlWorkScheduler.scheduleImageRefreshWork(newRefreshRateSec) + } else { + Timber.tag(TAG).d("Refresh rate is unchanged, not updating") + } + } + + // Workaround for periodic work not updating correctly (might be because of 🐛 bug in library) + conditionallyUpdateImageForPeriodicWork(tags, trmnlDisplayInfo.imageUrl, trmnlDisplayInfo.refreshIntervalSeconds) + + Timber.tag(TAG).i("Image refresh successful for work($tags), got new URL: ${trmnlDisplayInfo.imageUrl}") + return Result.success( + workDataOf( + KEY_REFRESH_RESULT to SUCCESS.name, + KEY_NEW_IMAGE_URL to trmnlDisplayInfo.imageUrl, + ), + ) + } + + /** + * There is potentially a bug where periodic work is not updated correctly. + * This function will check if the image URL is different from the one in the store. + * + * https://stackoverflow.com/questions/51476480/workstatus-observer-always-in-enqueued-state + */ + private fun conditionallyUpdateImageForPeriodicWork( + tags: Set, + imageUrl: String, + refreshIntervalSecs: Long?, + ) { + if (tags.contains(IMAGE_REFRESH_PERIODIC_WORK_TAG)) { + Timber.tag(TAG).d("Periodic work detected, updating image URL from result") + trmnlImageUpdateManager.updateImage(imageUrl, refreshIntervalSecs) + } + } + + /** + * Factory class for creating instances of [TrmnlImageRefreshWorker] with additional dependency using DI. + * + * @see TrmnlWorkerFactory + * @see WorkerModule + */ + class Factory + @Inject + constructor( + private val displayRepository: TrmnlDisplayRepository, + private val trmnlTokenDataStore: TrmnlTokenDataStore, + private val refreshLogManager: TrmnlRefreshLogManager, + private val trmnlWorkScheduler: TrmnlWorkScheduler, + private val trmnlImageUpdateManager: TrmnlImageUpdateManager, + ) { + fun create( + appContext: Context, + params: WorkerParameters, + ): TrmnlImageRefreshWorker = + TrmnlImageRefreshWorker( + appContext = appContext, + params = params, + displayRepository = displayRepository, + trmnlTokenDataStore = trmnlTokenDataStore, + refreshLogManager = refreshLogManager, + trmnlWorkScheduler = trmnlWorkScheduler, + trmnlImageUpdateManager = trmnlImageUpdateManager, + ) + } +} diff --git a/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageUpdateManager.kt b/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageUpdateManager.kt new file mode 100644 index 0000000..e5f5cfa --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/TrmnlImageUpdateManager.kt @@ -0,0 +1,58 @@ +package dev.hossain.trmnl.work + +import com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.data.ImageMetadata +import dev.hossain.trmnl.data.ImageMetadataStore +import dev.hossain.trmnl.di.AppScope +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber + +/** + * Manages the image update process for the TRMNL mirror display. + * Acts as a central source of truth for image updates from WorkManager. + */ +@SingleIn(AppScope::class) +class TrmnlImageUpdateManager + @Inject + constructor( + private val imageMetadataStore: ImageMetadataStore, + ) { + private val _imageUpdateFlow = MutableStateFlow(null) + val imageUpdateFlow: StateFlow = _imageUpdateFlow.asStateFlow() + + /** + * Updates the image URL and notifies observers through the flow. + * Only accepts updates with newer timestamps than the current image. + */ + fun updateImage( + imageUrl: String, + refreshIntervalSecs: Long? = null, + errorMessage: String? = null, + ) { + val imageMetadata = + ImageMetadata( + url = imageUrl, + refreshIntervalSecs = refreshIntervalSecs, + errorMessage = errorMessage, + ) + + Timber.d("Updating image URL from TrmnlImageUpdateManager: $imageMetadata") + + _imageUpdateFlow.value = imageMetadata + } + + /** + * Initialize the manager with the last cached image URL if available + */ + suspend fun initialize() { + imageMetadataStore.imageMetadataFlow.collect { metadata -> + if (metadata != null && _imageUpdateFlow.value == null) { + Timber.d("Initializing image URL from ImageMetadataStore cache: ${metadata.url}") + _imageUpdateFlow.value = metadata + } + } + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkScheduler.kt b/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkScheduler.kt new file mode 100644 index 0000000..75baaad --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkScheduler.kt @@ -0,0 +1,236 @@ +package dev.hossain.trmnl.work + +import android.content.Context +import androidx.lifecycle.asFlow +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkRequest +import androidx.work.workDataOf +import com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.data.TrmnlTokenDataStore +import dev.hossain.trmnl.di.AppScope +import dev.hossain.trmnl.di.ApplicationContext +import dev.hossain.trmnl.work.TrmnlImageRefreshWorker.Companion.PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE +import dev.hossain.trmnl.work.TrmnlImageRefreshWorker.Companion.PARAM_REFRESH_WORK_TYPE +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** + * Manages the scheduling and execution of background work using WorkManager. + * This includes scheduling periodic image refresh work and handling one-time work requests. + * + * @param context The application context. + * @param trmnlTokenDataStore The token manager for managing authentication tokens. + */ +@SingleIn(AppScope::class) +class TrmnlWorkScheduler + @Inject + constructor( + @ApplicationContext private val context: Context, + private val trmnlTokenDataStore: TrmnlTokenDataStore, + ) { + companion object { + internal const val IMAGE_REFRESH_PERIODIC_WORK_NAME = "trmnl_image_refresh_work_periodic" + internal const val IMAGE_REFRESH_PERIODIC_WORK_TAG = "trmnl_image_refresh_work_periodic_tag" + internal const val IMAGE_REFRESH_ONETIME_WORK_NAME = "trmnl_image_refresh_work_onetime" + internal const val IMAGE_REFRESH_ONETIME_WORK_TAG = "trmnl_image_refresh_work_onetime_tag" + + /** + * Minimum interval for periodic work in minutes. + * This is the minimum interval required by WorkManager for periodic work. + * + * - https://developer.android.com/reference/androidx/work/PeriodicWorkRequest + * - https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work + */ + private const val WORK_MANAGER_MINIMUM_INTERVAL_MINUTES = 15L + + /** + * When loading current image of the TRMNL, we add this delay before fetching + * the image allowing the server to render the image and save it in cloud. + */ + private const val EXTRA_REFRESH_WAIT_TIME_SEC: Long = 60L // 60 seconds + } + + /** + * Schedule periodic image refresh work + */ + fun scheduleImageRefreshWork(intervalSeconds: Long) { + // Check if we already have work scheduled + val workInfos = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .get() + + val existingWork = workInfos.firstOrNull() + if (existingWork != null) { + Timber.d("Existing work found: ${existingWork.state}") + + // Optional: Get the existing work details + val nextScheduleTimeMillis = existingWork.nextScheduleTimeMillis + val nextScheduleTime = java.time.Instant.ofEpochMilli(nextScheduleTimeMillis) + Timber.d("Next schedule time: $nextScheduleTimeMillis ($nextScheduleTime)") + } else { + Timber.d("No existing work found, will create new work") + } + + // Add extra wait time to interval and convert seconds to minutes + val adjustedIntervalSeconds = intervalSeconds + EXTRA_REFRESH_WAIT_TIME_SEC + val intervalMinutes = (adjustedIntervalSeconds / 60).coerceAtLeast(WORK_MANAGER_MINIMUM_INTERVAL_MINUTES) + + Timber.d("Scheduling work: $intervalSeconds seconds + $EXTRA_REFRESH_WAIT_TIME_SEC seconds → $intervalMinutes minutes") + + if (trmnlTokenDataStore.hasTokenSync().not()) { + Timber.w("Token not set, skipping image refresh work scheduling") + return + } + + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicWorkRequest = + PeriodicWorkRequestBuilder( + repeatInterval = intervalMinutes, + repeatIntervalTimeUnit = TimeUnit.MINUTES, + ).setConstraints(constraints) + .setBackoffCriteria( + // Exponential backoff for retrying failed work + // To avoid overwhelming the server with requests + BackoffPolicy.EXPONENTIAL, + WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, + TimeUnit.MILLISECONDS, + ).setInputData( + workDataOf( + PARAM_REFRESH_WORK_TYPE to RefreshWorkType.PERIODIC.name, + ), + ).addTag(IMAGE_REFRESH_PERIODIC_WORK_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + uniqueWorkName = IMAGE_REFRESH_PERIODIC_WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = periodicWorkRequest, + ) + } + + /** + * Start a one-time image refresh work immediately with option to load next playlist item image. + */ + fun startOneTimeImageRefreshWork(loadNextPlaylistImage: Boolean = false) { + Timber.d("Starting one-time image refresh work with loadNextPlaylistImage: $loadNextPlaylistImage") + + if (trmnlTokenDataStore.hasTokenSync().not()) { + Timber.w("Token not set, skipping one-time image refresh work") + return + } + + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val workRequest = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, + TimeUnit.MILLISECONDS, + ).setInputData( + workDataOf( + PARAM_REFRESH_WORK_TYPE to RefreshWorkType.ONE_TIME.name, + PARAM_LOAD_NEXT_PLAYLIST_DISPLAY_IMAGE to loadNextPlaylistImage, + ), + ).addTag(IMAGE_REFRESH_ONETIME_WORK_TAG) + .build() + + WorkManager + .getInstance(context) + .enqueueUniqueWork( + uniqueWorkName = IMAGE_REFRESH_ONETIME_WORK_NAME, + existingWorkPolicy = ExistingWorkPolicy.REPLACE, + request = workRequest, + ) + } + + /** + * Cancel scheduled image refresh work + */ + fun cancelImageRefreshWork() { + WorkManager.getInstance(context).cancelUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + } + + /** + * Checks if the image refresh work is already scheduled + * @return Flow of Boolean that emits true if work is scheduled + */ + fun isImageRefreshWorkScheduled(): Flow { + val workQuery = + WorkQuery.Builder + .fromUniqueWorkNames(listOf(IMAGE_REFRESH_PERIODIC_WORK_NAME)) + .addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED)) + .build() + + return WorkManager + .getInstance(context) + .getWorkInfosLiveData(workQuery) + .asFlow() + .map { workInfoList -> workInfoList.isNotEmpty() } + } + + /** + * Synchronously checks if image refresh work is scheduled + * @return true if work is scheduled + */ + fun isImageRefreshWorkScheduledSync(): Boolean { + val workInfos: List = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWork(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .get() + + return workInfos.any { + it.state == WorkInfo.State.RUNNING || + it.state == WorkInfo.State.ENQUEUED || + it.state == WorkInfo.State.BLOCKED + } + } + + /** + * Get the scheduled work info as a Flow to get updates on upcoming refresh job. + */ + fun getScheduledWorkInfo(): Flow = + WorkManager + .getInstance(context) + .getWorkInfosForUniqueWorkLiveData(IMAGE_REFRESH_PERIODIC_WORK_NAME) + .asFlow() + .map { it.firstOrNull() } + + /** + * Update the refresh interval based on server response + */ + suspend fun updateRefreshInterval(newIntervalSeconds: Long) { + Timber.d("Updating refresh interval to $newIntervalSeconds seconds") + + // Save the refresh rate to TokenManager + trmnlTokenDataStore.saveRefreshRateSeconds(newIntervalSeconds) + + // Reschedule with new interval + scheduleImageRefreshWork(newIntervalSeconds) + } + } diff --git a/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkerFactory.kt b/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkerFactory.kt new file mode 100644 index 0000000..0b96c89 --- /dev/null +++ b/app/src/main/java/dev/hossain/trmnl/work/TrmnlWorkerFactory.kt @@ -0,0 +1,27 @@ +package dev.hossain.trmnl.work + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import com.squareup.anvil.annotations.optional.SingleIn +import dev.hossain.trmnl.di.AppScope +import javax.inject.Inject + +@SingleIn(AppScope::class) +class TrmnlWorkerFactory + @Inject + constructor( + private val imageRefreshWorkerFactory: TrmnlImageRefreshWorker.Factory, + ) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker? = + when (workerClassName) { + TrmnlImageRefreshWorker::class.java.name -> + imageRefreshWorkerFactory.create(appContext, workerParameters) + else -> null + } + } diff --git a/app/src/main/res/drawable/counter_1_24dp.xml b/app/src/main/res/drawable/counter_1_24dp.xml new file mode 100644 index 0000000..035ba68 --- /dev/null +++ b/app/src/main/res/drawable/counter_1_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7b3e83f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/image_24dp.xml b/app/src/main/res/drawable/image_24dp.xml new file mode 100644 index 0000000..5fd63ec --- /dev/null +++ b/app/src/main/res/drawable/image_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/question_mark_24dp.xml b/app/src/main/res/drawable/question_mark_24dp.xml new file mode 100644 index 0000000..59b5fd1 --- /dev/null +++ b/app/src/main/res/drawable/question_mark_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/time_auto_24dp.xml b/app/src/main/res/drawable/time_auto_24dp.xml new file mode 100644 index 0000000..7728c3d --- /dev/null +++ b/app/src/main/res/drawable/time_auto_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/timer_24dp.xml b/app/src/main/res/drawable/timer_24dp.xml new file mode 100644 index 0000000..dc26327 --- /dev/null +++ b/app/src/main/res/drawable/timer_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trmnl_logo_plain.xml b/app/src/main/res/drawable/trmnl_logo_plain.xml new file mode 100644 index 0000000..29f7783 --- /dev/null +++ b/app/src/main/res/drawable/trmnl_logo_plain.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/trmnl_logo_semi_transparent.xml b/app/src/main/res/drawable/trmnl_logo_semi_transparent.xml new file mode 100644 index 0000000..94f4bf5 --- /dev/null +++ b/app/src/main/res/drawable/trmnl_logo_semi_transparent.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/visibility_24dp.xml b/app/src/main/res/drawable/visibility_24dp.xml new file mode 100644 index 0000000..c9e095f --- /dev/null +++ b/app/src/main/res/drawable/visibility_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/visibility_off_24dp.xml b/app/src/main/res/drawable/visibility_off_24dp.xml new file mode 100644 index 0000000..899a185 --- /dev/null +++ b/app/src/main/res/drawable/visibility_off_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..b63509e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f5bf417 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..f760898 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..db8e1d8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..5073ab4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9d256fd Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..d67a5d2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8d42184 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..cf37843 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..97f99d7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-v23/font_certs.xml b/app/src/main/res/values-v23/font_certs.xml new file mode 100644 index 0000000..207b62f --- /dev/null +++ b/app/src/main/res/values-v23/font_certs.xml @@ -0,0 +1,32 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..96da163 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F86527 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..945d478 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + TRMNL Display + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..64ee07f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/project-resources/banner/repository-open-graph-template.png b/project-resources/banner/repository-open-graph-template.png new file mode 100644 index 0000000..d03dffd Binary files /dev/null and b/project-resources/banner/repository-open-graph-template.png differ diff --git a/project-resources/banner/trmnl-mirror-banner.jpg b/project-resources/banner/trmnl-mirror-banner.jpg new file mode 100644 index 0000000..e9f9438 Binary files /dev/null and b/project-resources/banner/trmnl-mirror-banner.jpg differ diff --git a/project-resources/banner/trmnl-mirror-banner.png b/project-resources/banner/trmnl-mirror-banner.png new file mode 100644 index 0000000..508ed3f Binary files /dev/null and b/project-resources/banner/trmnl-mirror-banner.png differ diff --git a/project-resources/banner/trmnl-mirror-banner.pxd b/project-resources/banner/trmnl-mirror-banner.pxd new file mode 100644 index 0000000..c051e87 Binary files /dev/null and b/project-resources/banner/trmnl-mirror-banner.pxd differ diff --git a/project-resources/logo/android-outline.afdesign b/project-resources/logo/android-outline.afdesign new file mode 100644 index 0000000..0cf9afa Binary files /dev/null and b/project-resources/logo/android-outline.afdesign differ diff --git a/project-resources/logo/app-icon-bg-color.txt b/project-resources/logo/app-icon-bg-color.txt new file mode 100644 index 0000000..6f2e5c1 --- /dev/null +++ b/project-resources/logo/app-icon-bg-color.txt @@ -0,0 +1,2 @@ +F86527 + diff --git a/project-resources/logo/app-icon-blank.afdesign b/project-resources/logo/app-icon-blank.afdesign new file mode 100644 index 0000000..298fecb Binary files /dev/null and b/project-resources/logo/app-icon-blank.afdesign differ diff --git a/project-resources/logo/app-icon-blank.svg b/project-resources/logo/app-icon-blank.svg new file mode 100644 index 0000000..6d81b37 --- /dev/null +++ b/project-resources/logo/app-icon-blank.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project-resources/logo/app-icon.afdesign b/project-resources/logo/app-icon.afdesign new file mode 100644 index 0000000..0247ce8 Binary files /dev/null and b/project-resources/logo/app-icon.afdesign differ diff --git a/project-resources/logo/app-icon.svg b/project-resources/logo/app-icon.svg new file mode 100644 index 0000000..acab82f --- /dev/null +++ b/project-resources/logo/app-icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project-resources/logo/apple-touch-icon-180x180.png b/project-resources/logo/apple-touch-icon-180x180.png new file mode 100644 index 0000000..e0fee83 Binary files /dev/null and b/project-resources/logo/apple-touch-icon-180x180.png differ diff --git a/project-resources/logo/duplicate-outline-trmnl.afdesign b/project-resources/logo/duplicate-outline-trmnl.afdesign new file mode 100644 index 0000000..2a902e4 Binary files /dev/null and b/project-resources/logo/duplicate-outline-trmnl.afdesign differ diff --git a/project-resources/logo/duplicate-outline-trmnl.svg b/project-resources/logo/duplicate-outline-trmnl.svg new file mode 100644 index 0000000..33e11db --- /dev/null +++ b/project-resources/logo/duplicate-outline-trmnl.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project-resources/logo/duplicate-outline.svg b/project-resources/logo/duplicate-outline.svg new file mode 100644 index 0000000..c2225bf --- /dev/null +++ b/project-resources/logo/duplicate-outline.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/project-resources/logo/idea-icon.afdesign b/project-resources/logo/idea-icon.afdesign new file mode 100644 index 0000000..6303334 Binary files /dev/null and b/project-resources/logo/idea-icon.afdesign differ diff --git a/project-resources/logo/idea-icon.svg b/project-resources/logo/idea-icon.svg new file mode 100644 index 0000000..a725b48 --- /dev/null +++ b/project-resources/logo/idea-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/project-resources/logo/trmnl--glyph-black.svg b/project-resources/logo/trmnl--glyph-black.svg new file mode 100644 index 0000000..6d8bbd0 --- /dev/null +++ b/project-resources/logo/trmnl--glyph-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..456443b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TRMNL" +include(":app")