diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3622fd1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.nooktrmnl" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.nooktrmnl" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.1" + } + + 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 + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } +} + +dependencies { + implementation(libs.androidx.appcompat) + val composeBomVersion = "2023.10.01" + + implementation(platform("androidx.compose:compose-bom:$composeBomVersion")) + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("io.coil-kt:coil-compose:2.5.0") + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + implementation("androidx.work:work-runtime-ktx:2.9.0") +} \ No newline at end of file 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/com/example/nooktrmnl/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/nooktrmnl/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e8b4006 --- /dev/null +++ b/app/src/androidTest/java/com/example/nooktrmnl/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.nooktrmnl + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.nooktrmnl", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1913a18 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/nooktrmnl/DisplayUpdateWorker.kt b/app/src/main/java/com/example/nooktrmnl/DisplayUpdateWorker.kt new file mode 100644 index 0000000..7997374 --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/DisplayUpdateWorker.kt @@ -0,0 +1,210 @@ +package com.example.nooktrmnl + +import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +class DisplayUpdateWorker( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + companion object { + const val KEY_REFRESH_RATE = "refresh_rate" + const val ACTION_NETWORK_COMPLETE = "com.example.nooktrmnl.ACTION_NETWORK_COMPLETE" + const val ACTION_SCREEN_REFRESH_COMPLETE = "com.example.nooktrmnl.ACTION_SCREEN_REFRESH_COMPLETE" + private const val NETWORK_TIMEOUT_MS = 30000L + private const val SCREEN_REFRESH_TIMEOUT_MS = 15000L + private const val LOG_FILE_SIZE_LIMIT = 1024 * 1024 + } + + private var networkComplete = false + private var screenRefreshComplete = false + private var actualRefreshRate = 60 + + private fun logActivity(message: String) { + try { + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date()) + val logEntry = "$timestamp - DisplayUpdateWorker: $message\n" + + val logFile = File(applicationContext.getExternalFilesDir(null), "update_activity.log") + + if (logFile.exists() && logFile.length() > LOG_FILE_SIZE_LIMIT) { + val content = logFile.readText() + val lines = content.lines() + val keepLines = lines.takeLast(lines.size / 2) + logFile.writeText(keepLines.joinToString("\n") + "\n") + } + + logFile.appendText(logEntry) + Log.d("DisplayUpdateWorker", message) + } catch (e: Exception) { + Log.e("DisplayUpdateWorker", "Failed to write activity log", e) + } + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + logActivity("Starting update work") + + val powerManager = PowerManagerNook(applicationContext) + + logActivity("Ensuring WiFi connection") + var wifiAttempts = 0 + var wifiSuccess = false + + while (!wifiSuccess && wifiAttempts < 3) { + wifiSuccess = powerManager.ensureWifiOn() + if (!wifiSuccess) { + val backoffDelay = (wifiAttempts + 1) * 2000L + logActivity("WiFi enable attempt ${wifiAttempts + 1} failed, waiting ${backoffDelay}ms") + delay(backoffDelay) + wifiAttempts++ + } + logActivity("WiFi attempt $wifiAttempts status: $wifiSuccess") + } + + if (!wifiSuccess) { + logActivity("Failed to enable WiFi after 3 attempts") + powerManager.goToSleep() + return@withContext Result.retry() + } + + logActivity("WiFi connection established successfully") + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_NETWORK_COMPLETE -> { + logActivity("Network completion notification received") + val newRefreshRate = intent.getIntExtra("refresh_rate", 60) + val source = intent.getStringExtra("from_source") ?: "unknown" + logActivity("Received refresh rate: ${newRefreshRate}s from source: $source") + + if (newRefreshRate > 0) { + actualRefreshRate = newRefreshRate + logActivity("Updated refresh rate to ${actualRefreshRate}s") + } + networkComplete = true + } + ACTION_SCREEN_REFRESH_COMPLETE -> { + logActivity("Screen refresh completion notification received") + screenRefreshComplete = true + } + } + } + } + + val filter = IntentFilter().apply { + addAction(ACTION_NETWORK_COMPLETE) + addAction(ACTION_SCREEN_REFRESH_COMPLETE) + } + LocalBroadcastManager.getInstance(applicationContext).registerReceiver(receiver, filter) + + try { + val refreshRate = inputData.getInt(KEY_REFRESH_RATE, 60) + + val intent = Intent(MainActivity.ACTION_DISPLAY_UPDATE).apply { + putExtra("trigger_update", true) + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) + + logActivity("Waiting for network requests to complete...") + val networkSuccess = withTimeoutOrNull(NETWORK_TIMEOUT_MS) { + while (!networkComplete) { + delay(500) + if (System.currentTimeMillis() % 2000 < 500) { + logActivity("Still waiting for network completion...") + } + } + true + } ?: false + + if (networkSuccess) { + logActivity("Network requests completed successfully") + + logActivity("Waiting for screen refresh to complete...") + val screenSuccess = withTimeoutOrNull(SCREEN_REFRESH_TIMEOUT_MS) { + while (!screenRefreshComplete) { + delay(500) + if (System.currentTimeMillis() % 1000 < 500) { + logActivity("Still waiting for screen refresh...") + } + } + true + } ?: false + + if (screenSuccess) { + logActivity("Screen refresh completed successfully") + } else { + logActivity("Screen refresh timed out after ${SCREEN_REFRESH_TIMEOUT_MS}ms - proceeding anyway") + } + } else { + logActivity("Network requests timed out after ${NETWORK_TIMEOUT_MS}ms") + actualRefreshRate = refreshRate + logActivity("Using fallback refresh rate: ${actualRefreshRate}s") + } + + logActivity("Scheduling next update") + scheduleNextUpdate(applicationContext, actualRefreshRate) + + } finally { + LocalBroadcastManager.getInstance(applicationContext).unregisterReceiver(receiver) + } + + logActivity("Going back to sleep") + powerManager.goToSleep() + + logActivity("Update work completed successfully") + Result.success() + } catch (e: Exception) { + logActivity("Error during update: ${e.message}") + try { + val powerManager = PowerManagerNook(applicationContext) + powerManager.goToSleep() + } catch (sleepError: Exception) { + logActivity("Failed to go to sleep after error: ${sleepError.message}") + } + Result.retry() + } + } + + @SuppressLint("ScheduleExactAlarm") + private fun scheduleNextUpdate(context: Context, refreshRate: Int) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val updateIntent = Intent(context, UpdateReceiver::class.java).apply { + action = MainActivity.ACTION_UPDATE + } + val pendingIntent = PendingIntent.getBroadcast( + context, + MainActivity.UPDATE_REQUEST_CODE, + updateIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val nextUpdateTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(refreshRate.toLong()) + + alarmManager.setAlarmClock( + AlarmManager.AlarmClockInfo(nextUpdateTime, pendingIntent), + pendingIntent + ) + + logActivity("Next update scheduled for: ${Date(nextUpdateTime)} (${refreshRate}s from now)") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/HeadphoneReceiver.kt b/app/src/main/java/com/example/nooktrmnl/HeadphoneReceiver.kt new file mode 100644 index 0000000..ea8a7db --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/HeadphoneReceiver.kt @@ -0,0 +1,58 @@ +package com.example.nooktrmnl + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class HeadphoneReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_HEADSET_PLUG) { + val state = intent.getIntExtra("state", -1) + Log.d("HeadphoneReceiver", "Headset state changed: $state") + + if (state == 1) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + Log.d("HeadphoneReceiver", "Attempting wake on headset connect") + + val powerManager = PowerManagerNook(context) + powerManager.wakeForUpdate() + + val workRequest = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putInt(DisplayUpdateWorker.KEY_REFRESH_RATE, MainActivity.refreshRate) + .build() + ) + .addTag("com.example.nooktrmnl.DisplayUpdateWorker") + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + "displayUpdate", + ExistingWorkPolicy.REPLACE, + workRequest + ) + + Log.d("HeadphoneReceiver", "DisplayUpdateWorker enqueued successfully") + Log.d("HeadphoneReceiver", "Headphone-triggered update completed - alarms left intact") + + } catch (e: Exception) { + Log.e("HeadphoneReceiver", "Error in HeadphoneReceiver", e) + } finally { + pendingResult.finish() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/MainActivity.kt b/app/src/main/java/com/example/nooktrmnl/MainActivity.kt new file mode 100644 index 0000000..4ec40a4 --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/MainActivity.kt @@ -0,0 +1,552 @@ +package com.example.nooktrmnl + +import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.BatteryManager +import android.os.Bundle +import android.provider.Settings +import android.util.DisplayMetrics +import android.util.Log +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +class MainActivity : ComponentActivity() { + + private var headphoneReceiver: HeadphoneReceiver? = null + private var updateReceiver: BroadcastReceiver? = null + + companion object { + var SHOW_DEBUG_INFO = false + var BASE_URL = "https://trmnl.app" + var MAC_ADDRESS = "your-mac-address" + var API_KEY = "your-api-key" + var USER_AGENT = "trmnl-display/1.5.11" + var refreshRate = 60 + + const val ACTION_UPDATE = "com.example.nooktrmnl.ACTION_UPDATE" + const val UPDATE_REQUEST_CODE = 123 + const val ACTION_DISPLAY_UPDATE = "com.example.nooktrmnl.ACTION_DISPLAY_UPDATE" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent?.action == ACTION_UPDATE) { + Log.d("MainActivity", "Launched from update alarm") + } + + requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + WindowCompat.setDecorFitsSystemWindows(window, false) + + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController?.let { controller -> + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } + + headphoneReceiver = HeadphoneReceiver() + val headsetFilter = IntentFilter(Intent.ACTION_HEADSET_PLUG) + registerReceiver(headphoneReceiver, headsetFilter) + + updateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_DISPLAY_UPDATE) { + Log.d("MainActivity", "Display update broadcast received - triggering recomposition") + } + } + } + val updateFilter = IntentFilter(ACTION_DISPLAY_UPDATE) + LocalBroadcastManager.getInstance(this).registerReceiver(updateReceiver!!, updateFilter) + + Log.d("MainActivity", "onCreate: Checking and requesting WRITE_SETTINGS permission") + checkAndRequestWriteSettingsPermission() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + } + } + + override fun onDestroy() { + super.onDestroy() + try { + headphoneReceiver?.let { + unregisterReceiver(it) + headphoneReceiver = null + } + } catch (e: Exception) { + Log.e("MainActivity", "Error unregistering headphone receiver", e) + } + + try { + updateReceiver?.let { + LocalBroadcastManager.getInstance(this).unregisterReceiver(it) + updateReceiver = null + } + } catch (e: Exception) { + Log.e("MainActivity", "Error unregistering update receiver", e) + } + } + + private fun checkAndRequestWriteSettingsPermission() { + Log.d("MainActivity", "checkAndRequestWriteSettingsPermission: Checking WRITE_SETTINGS permission") + if (!Settings.System.canWrite(this)) { + Log.d("MainActivity", "WRITE_SETTINGS permission not granted. Requesting permission.") + AlertDialog.Builder(this) + .setTitle("Permission Required") + .setMessage("The app needs permission to modify system settings for power management. Please grant this permission on the next screen.") + .setPositiveButton("OK") { _, _ -> + Log.d("MainActivity", "User agreed to request WRITE_SETTINGS permission.") + Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { + data = Uri.parse("package:$packageName") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(this) + } + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + Log.w("MainActivity", "User declined to request WRITE_SETTINGS permission.") + } + .show() + } else { + Log.d("MainActivity", "WRITE_SETTINGS permission already granted.") + loadSettings() + + setContent { + TrmnlDisplay() + } + + lifecycleScope.launch { + val powerManager = PowerManagerNook(this@MainActivity) + if (powerManager.ensureWifiOn()) { + Log.d("MainActivity", "WiFi ensured on first boot") + triggerInitialUpdate() + } else { + Log.w("MainActivity", "Failed to ensure WiFi on first boot") + } + } + } + } + + private fun triggerInitialUpdate() { + val workRequest = OneTimeWorkRequestBuilder() + .addTag("com.example.nooktrmnl.DisplayUpdateWorker") + .build() + + WorkManager.getInstance(this).enqueueUniqueWork( + "displayUpdate", + ExistingWorkPolicy.REPLACE, + workRequest + ) + } + + private fun loadSettings() { + val sharedPreferences = getSharedPreferences("AppSettings", Context.MODE_PRIVATE) + BASE_URL = sharedPreferences.getString("BASE_URL", BASE_URL) ?: BASE_URL + MAC_ADDRESS = sharedPreferences.getString("MAC_ADDRESS", MAC_ADDRESS) ?: MAC_ADDRESS + API_KEY = sharedPreferences.getString("API_KEY", API_KEY) ?: API_KEY + USER_AGENT = sharedPreferences.getString("USER_AGENT", USER_AGENT) ?: USER_AGENT + SHOW_DEBUG_INFO = sharedPreferences.getBoolean("SHOW_DEBUG_INFO", SHOW_DEBUG_INFO) + } +} + +@Composable +fun TrmnlDisplay() { + val context = LocalContext.current + var imageState: Bitmap? by remember { mutableStateOf(null) } + var refreshRate by remember { mutableStateOf(60) } + var debugInfo by remember { mutableStateOf("Initializing...") } + var lastUpdated by remember { mutableStateOf("") } + var batteryLevel by remember { mutableStateOf(100) } + var updateTrigger by remember { mutableStateOf(0) } + + val screenDimensions = remember { + val prefs = context.getSharedPreferences("AppSettings", Context.MODE_PRIVATE) + val width = prefs.getInt("screen_width", 0) + val height = prefs.getInt("screen_height", 0) + + if (width > 0 && height > 0) { + ScreenDimensions(width, height) + } else { + val dims = getScreenDimensions(context) + prefs.edit() + .putInt("screen_width", dims.width) + .putInt("screen_height", dims.height) + .apply() + dims + } + } + + DisposableEffect(Unit) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == MainActivity.ACTION_DISPLAY_UPDATE) { + Log.d("TrmnlDisplay", "Update broadcast received") + updateTrigger++ + } + } + } + val filter = IntentFilter(MainActivity.ACTION_DISPLAY_UPDATE) + LocalBroadcastManager.getInstance(context).registerReceiver(receiver, filter) + + onDispose { + LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver) + } + } + + LaunchedEffect(updateTrigger) { + if (updateTrigger == 0) { + Log.d("TrmnlDisplay", "Skipping initial network request - DisplayUpdateWorker will handle it") + return@LaunchedEffect + } + + val powerManager = PowerManagerNook(context) + + if (!powerManager.ensureWifiOn()) { + debugInfo = "Failed to enable WiFi" + Log.e("TrmnlDisplay", "Failed to enable WiFi") + return@LaunchedEffect + } + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val needsSetup = !isDeviceSetup(context) + if (needsSetup) { + try { + debugInfo = "Setting up device..." + setupDevice(client) + markDeviceSetup(context) + debugInfo = "Device setup successful" + } catch (e: Exception) { + Log.e("TrmnlDisplay", "Setup failed", e) + debugInfo = "Setup failed: ${e.message}" + return@LaunchedEffect + } + } + + var attempts = 0 + val maxAttempts = 3 + + while (attempts < maxAttempts) { + try { + debugInfo = "Updating..." + batteryLevel = getBatteryLevel(context) + + val displayData = fetchDisplayData( + client = client, + batteryLevel = batteryLevel, + pngWidth = screenDimensions.width, + pngHeight = screenDimensions.height + ) + + val imageUrl = displayData.getString("image_url").replace("\\u0026", "&") + refreshRate = displayData.optInt("refresh_rate", 60) + val filename = displayData.optString("filename", "display.png") + + Log.d("TrmnlDisplay", "API Response JSON: $displayData") + Log.d("TrmnlDisplay", "Parsed refresh rate: $refreshRate seconds") + + MainActivity.refreshRate = refreshRate + + imageState = loadImageOptimized(context, imageUrl, client) + + debugInfo = """ + URL: $imageUrl + File: $filename + Refresh: ${refreshRate}s + Screen: ${screenDimensions.width}x${screenDimensions.height} + Battery: ${batteryLevel}% + """.trimIndent() + + lastUpdated = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) + Log.d("TrmnlDisplay", "Update completed successfully") + + val completionIntent = Intent(DisplayUpdateWorker.ACTION_NETWORK_COMPLETE).apply { + putExtra("refresh_rate", refreshRate) + putExtra("from_source", "api_response") + } + Log.d("TrmnlDisplay", "Sending completion notification with refresh_rate: $refreshRate") + LocalBroadcastManager.getInstance(context).sendBroadcast(completionIntent) + + break + + } catch (e: Exception) { + attempts++ + if (attempts >= maxAttempts) { + Log.e("TrmnlDisplay", "Error fetching display after $maxAttempts attempts", e) + debugInfo = "Error: ${e.localizedMessage ?: e.message ?: "Unknown error"}" + + val completionIntent = Intent(DisplayUpdateWorker.ACTION_NETWORK_COMPLETE).apply { + putExtra("refresh_rate", MainActivity.refreshRate) + putExtra("from_source", "error_fallback") + } + Log.e("TrmnlDisplay", "Sending error completion notification with refresh_rate: ${MainActivity.refreshRate}") + LocalBroadcastManager.getInstance(context).sendBroadcast(completionIntent) + break + } else { + Log.e("TrmnlDisplay", "Update attempt $attempts failed", e) + delay(1000) + } + } + } + } + + LaunchedEffect(Unit) { + if (updateTrigger == 0) { + Log.d("TrmnlDisplay", "First launch - triggering initial update") + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + imageState?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "TRMNL Display", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + ) + } + + if (MainActivity.SHOW_DEBUG_INFO) { + Text( + text = debugInfo, + color = Color.Black, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background(Color.LightGray.copy(alpha = 0.5f)) + .padding(4.dp) + ) + } + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .background(Color.LightGray.copy(alpha = 0.5f)) + .padding(4.dp) + .clickable { + context.startActivity(Intent(context, SettingsActivity::class.java)) + } + ) { + Text( + text = "Battery: $batteryLevel%", + color = Color.Black, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "Updated: $lastUpdated", + color = Color.Black, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +suspend fun loadImageOptimized( + context: Context, + imageUrl: String, + client: OkHttpClient +): Bitmap? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(imageUrl) + .addHeader("Accept", "image/*") + .build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + response.body?.byteStream()?.use { inputStream -> + val bufferedStream = inputStream.buffered(8192) + + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.RGB_565 + } + + BitmapFactory.decodeStream(bufferedStream, null, options) + } + } else { + Log.e("ImageLoader", "Failed to load image: HTTP ${response.code}") + null + } + } + } catch (e: Exception) { + Log.e("ImageLoader", "Failed to load image", e) + null + } +} + +fun isDeviceSetup(context: Context): Boolean { + val prefs = context.getSharedPreferences("AppSettings", Context.MODE_PRIVATE) + val lastSetup = prefs.getLong("last_setup_time", 0) + val daysSinceSetup = (System.currentTimeMillis() - lastSetup) / (1000 * 60 * 60 * 24) + return daysSinceSetup < 7 +} + +fun markDeviceSetup(context: Context) { + val prefs = context.getSharedPreferences("AppSettings", Context.MODE_PRIVATE) + prefs.edit().putLong("last_setup_time", System.currentTimeMillis()).apply() +} + +suspend fun setupDevice(client: OkHttpClient) = withContext(Dispatchers.IO) { + val url = "${MainActivity.BASE_URL}/api/setup/" + Log.d("TrmnlDisplay", "Setting up device at URL: $url") + + val request = Request.Builder() + .url(url) + .addHeader("ID", MainActivity.MAC_ADDRESS) + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/json") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() + Log.e("TrmnlDisplay", "Setup error response: $errorBody") + throw IOException("Setup failed with code ${response.code}. Error: $errorBody") + } + + Log.d("TrmnlDisplay", "Device setup successful") + } +} + +suspend fun fetchDisplayData( + client: OkHttpClient, + batteryLevel: Int, + pngWidth: Int, + pngHeight: Int +): JSONObject = withContext(Dispatchers.IO) { + val url = "${MainActivity.BASE_URL}/api/display" + Log.d("TrmnlDisplay", "Requesting URL: $url") + + val request = Request.Builder() + .url(url) + .addHeader("ID", MainActivity.MAC_ADDRESS) + .addHeader("Access-Token", MainActivity.API_KEY) + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/json") + .addHeader("battery-level", batteryLevel.toString()) + .addHeader("png-width", pngWidth.toString()) + .addHeader("png-height", pngHeight.toString()) + .addHeader("rssi", "0") + .addHeader("User-Agent", MainActivity.USER_AGENT) + .build() + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorBody = response.body?.string() + Log.e("TrmnlDisplay", "Error response: $errorBody") + throw IOException("Unexpected code ${response.code}. Error: $errorBody") + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrEmpty()) { + throw IOException("Empty response body") + } + + try { + return@withContext JSONObject(responseBody) + } catch (e: Exception) { + Log.e("TrmnlDisplay", "Invalid JSON: $responseBody") + throw IOException("Invalid JSON response: ${e.message}") + } + } + } catch (e: Exception) { + Log.e("TrmnlDisplay", "Network error", e) + throw e + } +} + +data class ScreenDimensions(val width: Int, val height: Int) + +fun getScreenDimensions(context: Context): ScreenDimensions { + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val displayMetrics = DisplayMetrics() + + @Suppress("DEPRECATION") + windowManager.defaultDisplay.getMetrics(displayMetrics) + + return ScreenDimensions( + width = displayMetrics.heightPixels, + height = displayMetrics.widthPixels + ) +} + +fun getBatteryLevel(context: Context): Int { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + + return try { + batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + } catch (e: Exception) { + Log.e("BatteryLevel", "Could not retrieve battery level", e) + 100 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/PowerManagerNook.kt b/app/src/main/java/com/example/nooktrmnl/PowerManagerNook.kt new file mode 100644 index 0000000..751d797 --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/PowerManagerNook.kt @@ -0,0 +1,154 @@ +package com.example.nooktrmnl + +import android.content.ContentResolver +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import kotlinx.coroutines.delay + +class PowerManagerNook(private val context: Context) { + + private val contentResolver: ContentResolver = context.contentResolver + private val handler = Handler(Looper.getMainLooper()) + private val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private var wakeLock: PowerManager.WakeLock? = null + + companion object { + private const val POWER_ENHANCE_ENABLE = "power_enhance_enable" + private const val LOG_TAG = "NookTerminalPower" + private const val POWER_STATE_DELAY = 200L + private const val SLEEP_CHECK_DELAY = 50L + private const val NETWORK_CHECK_INTERVAL = 500L + private const val MAX_NETWORK_ATTEMPTS = 30 + private const val NETWORK_STABILIZE_DELAY = 1000L + } + + suspend fun ensureWifiOn(): Boolean { + if (isNetworkConnected()) { + Log.d(LOG_TAG, "Already connected to network") + return true + } + + if (!wifiManager.isWifiEnabled) { + Log.d(LOG_TAG, "WiFi is off, enabling") + wifiManager.isWifiEnabled = true + } + + return waitForNetwork() + } + + private suspend fun waitForNetwork(): Boolean { + var attempts = 0 + + while (attempts < MAX_NETWORK_ATTEMPTS) { + if (isNetworkConnected()) { + Log.d(LOG_TAG, "Network connected after ${attempts * NETWORK_CHECK_INTERVAL}ms") + delay(NETWORK_STABILIZE_DELAY) + return true + } + delay(NETWORK_CHECK_INTERVAL) + attempts++ + Log.d(LOG_TAG, "Waiting for network... Attempt $attempts") + } + + Log.e(LOG_TAG, "Failed to establish network connection") + return false + } + + private fun isNetworkConnected(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities?.let { + it.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + it.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } ?: false + } else { + @Suppress("DEPRECATION") + connectivityManager.activeNetworkInfo?.isConnected == true + } + } + + suspend fun wakeForUpdate() { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + + wakeLock = powerManager.newWakeLock( + PowerManager.FULL_WAKE_LOCK or + PowerManager.ACQUIRE_CAUSES_WAKEUP or + PowerManager.ON_AFTER_RELEASE, + "NookTerminal::FullWakeLock" + ).apply { + acquire(10 * 60 * 1000L) // 10 minute timeout + } + + val before = getPowerEnhanceState() + setPowerEnhanceState(0) + val after = getPowerEnhanceState() + + Log.d(LOG_TAG, "Wake - power_enhance_enable changed from $before to $after") + + delay(POWER_STATE_DELAY * 2) + + val actualPowerState = getPowerEnhanceState() + Log.d(LOG_TAG, "Power enhance state after delay: $actualPowerState") + + } catch (e: Exception) { + Log.e(LOG_TAG, "Error waking device", e) + } + } + + suspend fun goToSleep() { + try { + try { + if (wifiManager.isWifiEnabled) { + Log.d(LOG_TAG, "Disabling WiFi for sleep") + wifiManager.isWifiEnabled = false + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Error disabling WiFi", e) + } + + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + Log.d(LOG_TAG, "Wake lock released") + } + } + wakeLock = null + } catch (e: Exception) { + Log.e(LOG_TAG, "Error releasing wake lock", e) + } + + setPowerEnhanceState(0) + delay(POWER_STATE_DELAY) + setPowerEnhanceState(1) + + Log.d(LOG_TAG, "Sleep command sent - checking state") + + delay(SLEEP_CHECK_DELAY) + val state = getPowerEnhanceState() + Log.d(LOG_TAG, "Sleep state after delay: $state") + + } catch (e: Exception) { + Log.e(LOG_TAG, "Error putting device to sleep", e) + } + } + + private fun getPowerEnhanceState(): Int { + return Settings.System.getInt(contentResolver, POWER_ENHANCE_ENABLE, 0) + } + + private fun setPowerEnhanceState(state: Int) { + Settings.System.putInt(contentResolver, POWER_ENHANCE_ENABLE, state) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/SettingsActivity.kt b/app/src/main/java/com/example/nooktrmnl/SettingsActivity.kt new file mode 100644 index 0000000..282dc21 --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/SettingsActivity.kt @@ -0,0 +1,209 @@ +package com.example.nooktrmnl + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +class SettingsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SettingsScreen() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen() { + val context = LocalContext.current + var baseUrl by remember { mutableStateOf(MainActivity.BASE_URL) } + var macAddress by remember { mutableStateOf(MainActivity.MAC_ADDRESS) } + var apiKey by remember { mutableStateOf(MainActivity.API_KEY) } + var userAgent by remember { mutableStateOf(MainActivity.USER_AGENT) } + var showDebugInfo by remember { mutableStateOf(MainActivity.SHOW_DEBUG_INFO) } + + val timeoutOptions = listOf( + "2 minutes" to 120000, + "15 days" to 1296000000, + "30 days" to 2592000000L.toInt(), + "Never" to Int.MAX_VALUE + ) + + val currentTimeout = remember { + try { + Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, 60000) + } catch (e: Exception) { + 60000 + } + } + + val initialSelection = timeoutOptions.indexOfFirst { it.second == currentTimeout }.let { index -> + if (index >= 0) index else 0 + } + + var selectedTimeoutIndex by remember { mutableIntStateOf(initialSelection) } + var timeoutDropdownExpanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + label = { Text("Base URL") }, + modifier = Modifier.fillMaxWidth() + ) + + TextField( + value = macAddress, + onValueChange = { macAddress = it }, + label = { Text("MAC Address") }, + modifier = Modifier.fillMaxWidth() + ) + + TextField( + value = apiKey, + onValueChange = { apiKey = it }, + label = { Text("API Key") }, + modifier = Modifier.fillMaxWidth() + ) + + TextField( + value = userAgent, + onValueChange = { userAgent = it }, + label = { Text("User Agent") }, + modifier = Modifier.fillMaxWidth() + ) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Screen Timeout", + modifier = Modifier.padding(bottom = 4.dp) + ) + ExposedDropdownMenuBox( + expanded = timeoutDropdownExpanded, + onExpandedChange = { timeoutDropdownExpanded = !timeoutDropdownExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + TextField( + value = timeoutOptions[selectedTimeoutIndex].first, + onValueChange = { }, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeoutDropdownExpanded) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = timeoutDropdownExpanded, + onDismissRequest = { timeoutDropdownExpanded = false } + ) { + timeoutOptions.forEachIndexed { index, (label, _) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + selectedTimeoutIndex = index + timeoutDropdownExpanded = false + } + ) + } + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text("Show Debug Info") + Spacer(modifier = Modifier.weight(1f)) + Switch( + checked = showDebugInfo, + onCheckedChange = { showDebugInfo = it } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + saveSettings( + context = context, + baseUrl = baseUrl, + macAddress = macAddress, + apiKey = apiKey, + userAgent = userAgent, + showDebugInfo = showDebugInfo, + screenTimeoutMs = timeoutOptions[selectedTimeoutIndex].second + ) + (context as? ComponentActivity)?.finish() + }, + modifier = Modifier.align(Alignment.End), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = Color.Black + ), + border = BorderStroke(1.dp, Color.Black) + ) { + Text("Save") + } + } +} + +fun saveSettings( + context: Context, + baseUrl: String, + macAddress: String, + apiKey: String, + userAgent: String, + showDebugInfo: Boolean, + screenTimeoutMs: Int +) { + val sharedPreferences: SharedPreferences = context.getSharedPreferences("AppSettings", Context.MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.putString("BASE_URL", baseUrl) + editor.putString("MAC_ADDRESS", macAddress) + editor.putString("API_KEY", apiKey) + editor.putString("USER_AGENT", userAgent) + editor.putBoolean("SHOW_DEBUG_INFO", showDebugInfo) + editor.putInt("SCREEN_TIMEOUT_MS", screenTimeoutMs) + editor.apply() + + MainActivity.BASE_URL = baseUrl + MainActivity.MAC_ADDRESS = macAddress + MainActivity.API_KEY = apiKey + MainActivity.USER_AGENT = userAgent + MainActivity.SHOW_DEBUG_INFO = showDebugInfo + + try { + Settings.System.putInt( + context.contentResolver, + Settings.System.SCREEN_OFF_TIMEOUT, + screenTimeoutMs + ) + Log.d("SettingsActivity", "Screen timeout set to ${screenTimeoutMs}ms") + } catch (e: Exception) { + Log.e("SettingsActivity", "Failed to set screen timeout", e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/UpdateManager.kt b/app/src/main/java/com/example/nooktrmnl/UpdateManager.kt new file mode 100644 index 0000000..4f5873f --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/UpdateManager.kt @@ -0,0 +1,100 @@ +package com.example.nooktrmnl + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.util.Log +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +object UpdateManager { + + private const val WORK_NAME = "displayUpdate" + private const val WORK_TAG = "com.example.nooktrmnl.DisplayUpdateWorker" + + fun triggerUpdate(context: Context, triggerSource: String) { + val pendingResult = if (context is BroadcastReceiver) { + context.goAsync() + } else null + + CoroutineScope(Dispatchers.Default).launch { + try { + Log.d("UpdateManager", "Update triggered by: $triggerSource") + + logPowerState(context) + + if (triggerSource == "headphone_primary") { + cancelScheduledUpdates(context) + Log.d("UpdateManager", "Cancelled any pending alarms") + + val powerManager = PowerManagerNook(context) + powerManager.wakeForUpdate() + + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) + + val workRequest = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putInt(DisplayUpdateWorker.KEY_REFRESH_RATE, MainActivity.refreshRate) + .build() + ) + .addTag(WORK_TAG) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + workRequest + ) + + Log.d("UpdateManager", "DisplayUpdateWorker enqueued via headphone trigger") + } else if (triggerSource == "alarm_scheduler") { + Log.d("UpdateManager", "Alarm fired - but relying on headphone event for actual wake") + } + + } catch (e: Exception) { + Log.e("UpdateManager", "Error in update trigger ($triggerSource)", e) + } finally { + pendingResult?.finish() + } + } + } + + private fun logPowerState(context: Context) { + try { + val powerState = Settings.System.getInt( + context.contentResolver, + "power_enhance_enable", + -1 + ) + Log.d("UpdateManager", "Power state: $powerState") + } catch (e: Exception) { + Log.e("UpdateManager", "Error checking power state: ${e.message}") + } + } + + private fun cancelScheduledUpdates(context: Context) { + try { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = PendingIntent.getBroadcast( + context, + MainActivity.UPDATE_REQUEST_CODE, + Intent(context, UpdateReceiver::class.java).apply { + action = MainActivity.ACTION_UPDATE + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.cancel(pendingIntent) + } catch (e: Exception) { + Log.e("UpdateManager", "Error cancelling alarms", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/UpdateReceiver.kt b/app/src/main/java/com/example/nooktrmnl/UpdateReceiver.kt new file mode 100644 index 0000000..69c8a2f --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/UpdateReceiver.kt @@ -0,0 +1,51 @@ +package com.example.nooktrmnl + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UpdateReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == MainActivity.ACTION_UPDATE) { + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + Log.d("UpdateReceiver", "Alarm triggered - ensuring update happens") + + val powerManager = PowerManagerNook(context) + powerManager.wakeForUpdate() + + val workRequest = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putInt(DisplayUpdateWorker.KEY_REFRESH_RATE, MainActivity.refreshRate) + .build() + ) + .addTag("com.example.nooktrmnl.DisplayUpdateWorker") + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + "displayUpdate", + ExistingWorkPolicy.REPLACE, + workRequest + ) + + Log.d("UpdateReceiver", "DisplayUpdateWorker enqueued successfully") + } catch (e: Exception) { + Log.e("UpdateReceiver", "Error in UpdateReceiver", e) + } finally { + pendingResult.finish() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/ui/theme/Color.kt b/app/src/main/java/com/example/nooktrmnl/ui/theme/Color.kt new file mode 100644 index 0000000..177acd5 --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.nooktrmnl.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/ui/theme/Theme.kt b/app/src/main/java/com/example/nooktrmnl/ui/theme/Theme.kt new file mode 100644 index 0000000..68d314b --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.nooktrmnl.ui.theme + +import android.app.Activity +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 = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun NooktrmnlTheme( + 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 = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nooktrmnl/ui/theme/Type.kt b/app/src/main/java/com/example/nooktrmnl/ui/theme/Type.kt new file mode 100644 index 0000000..100129a --- /dev/null +++ b/app/src/main/java/com/example/nooktrmnl/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.nooktrmnl.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file 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..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 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..b2dfe3d 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..4f0f1d6 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..62b611d 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..948a307 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..1b9a695 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..28d4b77 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..9287f50 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..aa7d642 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..9126ae3 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/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/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5b6f719 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + trmnl-nook + Settings + \ 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..d99a4bd --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +