diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b567946..05a8bab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,6 +19,10 @@ android { versionName = "2.0" } + testOptions { + unitTests.isReturnDefaultValues = true + } + signingConfigs { create("release") { val keystorePropertiesFile = rootProject.file("keystore.properties") @@ -52,13 +56,28 @@ android { kotlinOptions { jvmTarget = "17" } + testOptions { + unitTests.isReturnDefaultValues = true + } } dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.11.1") + testImplementation("androidx.test:core:1.5.0") + testImplementation("androidx.test.ext:junit:1.1.5") implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") implementation("androidx.work:work-runtime-ktx:2.10.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + + testImplementation("junit:junit:4.13.2") + + testImplementation("org.mockito:mockito-core:5.11.0") + + testImplementation("org.mockito:mockito-core:5.8.0") + + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9c5cedd..d4eea55 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ = android.os.Build.VERSION_CODES.S) { + return if (isPrimary) { + // High contrast accent for time & battery + context.getColor(if (isLight) android.R.color.system_accent1_800 else android.R.color.system_accent1_50) + } else { + // Muted neutral for secondary items (temp, data, storage, steps) + context.getColor(if (isLight) android.R.color.system_neutral2_600 else android.R.color.system_neutral2_300) + } + } + return when (idx) { + 0 -> { // Default + if (isPrimary) { + if (isLight) context.getColor(R.color.widget_text_light) else android.graphics.Color.WHITE + } else { + if (isLight) context.getColor(R.color.widget_text_secondary_light) else android.graphics.Color.parseColor("#CCFFFFFF") + } + } + 1 -> if (sdkInt >= android.os.Build.VERSION_CODES.S) { + context.getColor(android.R.color.system_accent1_500) + } else { + android.graphics.Color.CYAN + } + 2 -> { + val prefix = if (isPrimary) "text_color_primary" else "text_color_secondary" + val r = prefs.getInt("${prefix}_r", 255).coerceIn(0, 255) + val g = prefs.getInt("${prefix}_g", 255).coerceIn(0, 255) + val b = prefs.getInt("${prefix}_b", 255).coerceIn(0, 255) + android.graphics.Color.rgb(r, g, b) + } + else -> if (isPrimary) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#CCFFFFFF") + } + } // Suspended function called from Coroutine fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, mode: UpdateMode = UpdateMode.FULL) { @@ -236,7 +283,7 @@ class AwidgetProvider : AppWidgetProvider() { val sizeStorage = prefs.getFloat("size_storage", 14f) var showTasks = prefs.getBoolean("show_tasks", false) - if (showTasks && androidx.core.content.ContextCompat.checkSelfPermission(context, "org.tasks.permission.READ_TASKS") != android.content.pm.PackageManager.PERMISSION_GRANTED) { + if (showTasks && androidx.core.content.ContextCompat.checkSelfPermission(context, PERMISSION_READ_TASKS_ORG) != android.content.pm.PackageManager.PERMISSION_GRANTED) { showTasks = false } val sizeTasks = prefs.getFloat("size_tasks", 14f) @@ -359,44 +406,8 @@ class AwidgetProvider : AppWidgetProvider() { } views.setInt(R.id.widget_outline, "setImageAlpha", 255) - // Resolve Colors - fun resolveColor(idx: Int, isPrimary: Boolean, isLight: Boolean): Int { - // When dynamic colors is on, always use dynamic palette regardless of saved color index - if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - return if (isPrimary) { - // High contrast accent for time & battery - context.getColor(if (isLight) android.R.color.system_accent1_800 else android.R.color.system_accent1_50) - } else { - // Muted neutral for secondary items (temp, data, storage, steps) - context.getColor(if (isLight) android.R.color.system_neutral2_600 else android.R.color.system_neutral2_300) - } - } - return when (idx) { - 0 -> { // Default - if (isPrimary) { - if (isLight) context.getColor(R.color.widget_text_light) else android.graphics.Color.WHITE - } else { - if (isLight) context.getColor(R.color.widget_text_secondary_light) else android.graphics.Color.parseColor("#CCFFFFFF") - } - } - 1 -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - context.getColor(android.R.color.system_accent1_500) - } else { - android.graphics.Color.CYAN - } - 2 -> { - val prefix = if (isPrimary) "text_color_primary" else "text_color_secondary" - val r = prefs.getInt("${prefix}_r", 255) - val g = prefs.getInt("${prefix}_g", 255) - val b = prefs.getInt("${prefix}_b", 255) - android.graphics.Color.rgb(r, g, b) - } - else -> if (isPrimary) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#CCFFFFFF") - } - } - - val primaryColor = resolveColor(textColorPrimaryIdx, true, useLightTheme) - val secondaryColor = resolveColor(textColorSecondaryIdx, false, useLightTheme) + val primaryColor = resolveColor(context, prefs, useDynamicColors, textColorPrimaryIdx, true, useLightTheme) + val secondaryColor = resolveColor(context, prefs, useDynamicColors, textColorSecondaryIdx, false, useLightTheme) // Slightly distinct colors for date and next alarm val dateColor = if (useDynamicColors && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { @@ -434,7 +445,7 @@ class AwidgetProvider : AppWidgetProvider() { batterySpannable.setSpan(android.text.style.RelativeSizeSpan(0.5f), batterySpannable.length - 1, batterySpannable.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) val tempInt = batteryStatus?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0 val tempVal = tempInt / 10f - if (showSteps) loadStepCount(context, tickViews) + if (showSteps) loadStepCount(context, tickViews, prefs) if (showBattery) tickViews.setTextViewText(R.id.text_battery, batterySpannable) if (showTemp) { val tempStr = String.format("%.1f", tempVal) @@ -447,8 +458,8 @@ class AwidgetProvider : AppWidgetProvider() { if (boldTemp) tempSpan.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, tempSpan.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) tickViews.setTextViewText(R.id.text_temp, tempSpan) } - if (showData) updateDataUsage(context, tickViews) - if (showStorage) updateStorageStats(context, tickViews) + if (showData) updateDataUsage(context, tickViews, prefs) + if (showStorage) updateStorageStats(context, tickViews, prefs) appWidgetManager.partiallyUpdateAppWidget(appWidgetId, tickViews) return } else if (mode == UpdateMode.CALENDAR_ONLY) { @@ -651,7 +662,7 @@ class AwidgetProvider : AppWidgetProvider() { if (showData) { views.setTextViewTextSize(R.id.text_data_usage, android.util.TypedValue.COMPLEX_UNIT_SP, sizeData) views.setTextColor(R.id.text_data_usage, secondaryColor) - updateDataUsage(context, views) + updateDataUsage(context, views, prefs) } // --- Storage --- @@ -659,7 +670,7 @@ class AwidgetProvider : AppWidgetProvider() { if (showStorage) { views.setTextViewTextSize(R.id.text_storage, android.util.TypedValue.COMPLEX_UNIT_SP, sizeStorage) views.setTextColor(R.id.text_storage, secondaryColor) - updateStorageStats(context, views) + updateStorageStats(context, views, prefs) } // --- Step Counter --- @@ -667,7 +678,7 @@ class AwidgetProvider : AppWidgetProvider() { if (showSteps) { views.setTextViewTextSize(R.id.text_steps, android.util.TypedValue.COMPLEX_UNIT_SP, sizeSteps) views.setTextColor(R.id.text_steps, secondaryColor) - loadStepCount(context, views) + loadStepCount(context, views, prefs) } // --- Screen Time --- @@ -677,7 +688,7 @@ class AwidgetProvider : AppWidgetProvider() { if (showScreenTime) { views.setTextViewTextSize(R.id.text_screen_time, android.util.TypedValue.COMPLEX_UNIT_SP, sizeScreenTime) views.setTextColor(R.id.text_screen_time, secondaryColor) - updateScreenTime(context, views) + updateScreenTime(context, views, prefs) } // --- Dynamic Spacing Logic for Both Sides --- @@ -825,25 +836,13 @@ class AwidgetProvider : AppWidgetProvider() { } - private fun loadCalendarEvents(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int, secondaryColor: Int) { - if (androidx.core.content.ContextCompat.checkSelfPermission( - context, android.Manifest.permission.READ_CALENDAR - ) != android.content.pm.PackageManager.PERMISSION_GRANTED) { - return - } + data class EventInfo(val id: Long, val title: String, val begin: Long, val isLocal: Boolean) - val eventViews = listOf( - R.id.text_event_1, R.id.text_event_2, R.id.text_event_3, - R.id.text_event_4, R.id.text_event_5, R.id.text_event_6, - R.id.text_event_7, R.id.text_event_8, R.id.text_event_9, - R.id.text_event_10 - ) + private fun fetchCalendarEvents(context: Context): List { + val syncedCalendarIds = mutableSetOf() + val visibleCalendarIds = mutableSetOf() - try { - val syncedCalendarIds = mutableSetOf() - val visibleCalendarIds = mutableSetOf() - - val calSelection = "${android.provider.CalendarContract.Calendars.VISIBLE} = 1" + val calSelection = "${android.provider.CalendarContract.Calendars.VISIBLE} = 1" context.contentResolver.query( android.provider.CalendarContract.Calendars.CONTENT_URI, @@ -856,12 +855,10 @@ class AwidgetProvider : AppWidgetProvider() { calSelection, null, null )?.use { cursor -> val idIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars._ID) - // val typeIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.ACCOUNT_TYPE) val nameIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.ACCOUNT_NAME) val displayIdx = cursor.getColumnIndex(android.provider.CalendarContract.Calendars.CALENDAR_DISPLAY_NAME) while (cursor.moveToNext()) { val calId = cursor.getLong(idIdx) - // val accountType = cursor.getString(typeIdx) ?: "" val accountName = cursor.getString(nameIdx) ?: "" val displayName = cursor.getString(displayIdx) ?: "" @@ -874,7 +871,7 @@ class AwidgetProvider : AppWidgetProvider() { } } - if (visibleCalendarIds.isEmpty()) return + if (visibleCalendarIds.isEmpty()) return emptyList() val projection = arrayOf( android.provider.CalendarContract.Instances.EVENT_ID, @@ -891,14 +888,11 @@ class AwidgetProvider : AppWidgetProvider() { .appendPath(endQuery.toString()) .build() - val idList = visibleCalendarIds.joinToString(",") - // Removed Instances.VISIBLE = 1 because some devices/ROMs crash if Instances table lacks this column. - // We are already filtering by CALENDAR_ID IN ($idList) which only contains visibly enabled calendars. - val selection = "${android.provider.CalendarContract.Instances.END} >= ? AND ${android.provider.CalendarContract.Instances.CALENDAR_ID} IN ($idList)" - val selectionArgs = arrayOf(now.toString()) + val idList = visibleCalendarIds.joinToString(",") + val selection = "${android.provider.CalendarContract.Instances.END} >= ? AND ${android.provider.CalendarContract.Instances.CALENDAR_ID} IN ($idList)" + val selectionArgs = arrayOf(now.toString()) val sortOrder = "${android.provider.CalendarContract.Instances.BEGIN} ASC" - data class EventInfo(val id: Long, val title: String, val begin: Long, val isLocal: Boolean) val events = mutableListOf() context.contentResolver.query(uri, projection, selection, selectionArgs, sortOrder)?.use { cursor -> @@ -916,8 +910,10 @@ class AwidgetProvider : AppWidgetProvider() { events.add(EventInfo(eventId, title, begin, isLocal)) } } + return events + } - // java.time formatters + private fun bindCalendarEvents(context: Context, views: RemoteViews, events: List, textSizeSp: Float, primaryColor: Int, secondaryColor: Int, eventViews: List) { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) val dayFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault()) val dateFormatter = DateTimeFormatter.ofPattern("d MMM h:mma", Locale.getDefault()) @@ -969,12 +965,31 @@ class AwidgetProvider : AppWidgetProvider() { data = android.content.ContentUris.withAppendedId(android.provider.CalendarContract.Events.CONTENT_URI, event.id) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } - val eventPendingIntent = PendingIntent.getActivity(context, event.id.toInt(), eventIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - views.setOnClickPendingIntent(eventViews[i], eventPendingIntent) - } else { - views.setViewVisibility(eventViews[i], android.view.View.GONE) - } + val eventPendingIntent = PendingIntent.getActivity(context, event.id.toInt(), eventIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + views.setOnClickPendingIntent(eventViews[i], eventPendingIntent) + } else { + views.setViewVisibility(eventViews[i], android.view.View.GONE) } + } + } + + private fun loadCalendarEvents(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int, secondaryColor: Int) { + if (androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.READ_CALENDAR + ) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + return + } + + val eventViews = listOf( + R.id.text_event_1, R.id.text_event_2, R.id.text_event_3, + R.id.text_event_4, R.id.text_event_5, R.id.text_event_6, + R.id.text_event_7, R.id.text_event_8, R.id.text_event_9, + R.id.text_event_10 + ) + + try { + val events = fetchCalendarEvents(context) + bindCalendarEvents(context, views, events, textSizeSp, primaryColor, secondaryColor, eventViews) } catch (e: Exception) { // Log and gracefully handle crash android.util.Log.e("LWidget", "Error loading calendar events", e) @@ -984,7 +999,7 @@ class AwidgetProvider : AppWidgetProvider() { } } - private fun updateScreenTime(context: Context, views: RemoteViews) { + private fun updateScreenTime(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) { val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager val mode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { appOps.unsafeCheckOpNoThrow(android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName) @@ -1019,7 +1034,6 @@ class AwidgetProvider : AppWidgetProvider() { } } - val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) val isBold = prefs.getBoolean("bold_screen_time", false) if (totalForegroundTime > 0) { @@ -1039,7 +1053,7 @@ class AwidgetProvider : AppWidgetProvider() { } } - private fun updateDataUsage(context: Context, views: RemoteViews) { + private fun updateDataUsage(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) { val networkStatsManager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager // Use java.time val startOfDay = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() @@ -1069,7 +1083,6 @@ class AwidgetProvider : AppWidgetProvider() { span } - val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) if (prefs.getBoolean("bold_data_usage", false) && text is android.text.SpannableString) { text.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, text.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } @@ -1086,6 +1099,61 @@ class AwidgetProvider : AppWidgetProvider() { } } + private data class TaskData(val title: String, val dueMillis: Long) + + private fun fetchActiveTasks(context: Context, limit: Int): List { + val tasks = mutableListOf() + val taskUri = android.net.Uri.parse("content://org.tasks/tasks") + val selection = "completed=0 AND deleted=0" + try { + context.contentResolver.query(taskUri, null, selection, null, "dueDate ASC")?.use { cursor -> + val titleIdx = cursor.getColumnIndex("title") + val compIdx = cursor.getColumnIndex("completed") + val delIdx = cursor.getColumnIndex("deleted") + val dueIdx = cursor.getColumnIndex("dueDate") + + if (titleIdx == -1) return emptyList() + + while (cursor.moveToNext() && tasks.size < limit) { + val completed = if (compIdx >= 0) cursor.getString(compIdx) else null + val deleted = if (delIdx >= 0) cursor.getString(delIdx) else null + val dueMillis = if (dueIdx >= 0) cursor.getLong(dueIdx) else 0L + + val isCompleted = completed != null && completed != "0" + val isDeleted = deleted != null && deleted != "0" + + if (isCompleted || isDeleted) { + continue + } + + val title = cursor.getString(titleIdx) ?: "No Title" + tasks.add(TaskData(title, dueMillis)) + } + } + } catch (e: Exception) { + // Return empty list on failure + } + return tasks + } + + private fun formatDueSuffix(dueMillis: Long): String { + if (dueMillis <= 0) return "" + val dueDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(dueMillis), ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + + return if (dueDate.isBefore(today)) { + " (Overdue)" + } else if (dueDate.isEqual(today)) { + " (Today)" + } else if (dueDate.isEqual(tomorrow)) { + " (Tomorrow)" + } else { + val df = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) + " (${dueDate.format(df)})" + } + } + private fun loadTasks(context: Context, views: RemoteViews, textSizeSp: Float, primaryColor: Int) { val eventViews = listOf( R.id.text_event_1, R.id.text_event_2, R.id.text_event_3, @@ -1095,107 +1163,50 @@ class AwidgetProvider : AppWidgetProvider() { ) // Debugging: Check permission again contextually - val hasPerm = context.checkSelfPermission("org.tasks.permission.READ_TASKS") == android.content.pm.PackageManager.PERMISSION_GRANTED || - context.checkSelfPermission("com.todoroo.astrid.READ") == android.content.pm.PackageManager.PERMISSION_GRANTED + val hasPerm = context.checkSelfPermission(PERMISSION_READ_TASKS_ORG) == android.content.pm.PackageManager.PERMISSION_GRANTED || + context.checkSelfPermission(PERMISSION_READ_TASKS_ASTRID) == android.content.pm.PackageManager.PERMISSION_GRANTED if (!hasPerm) { views.setTextViewText(eventViews[0], "Missing Permission") views.setViewVisibility(eventViews[0], android.view.View.VISIBLE) + for (j in 1 until eventViews.size) { + views.setViewVisibility(eventViews[j], android.view.View.GONE) + } return } + val tasks = fetchActiveTasks(context, eventViews.size) - val taskUri = android.net.Uri.parse("content://org.tasks/tasks") - // Selection appears to be ignored by provider, so we select all and filter manually - val selection = "completed=0 AND deleted=0" - - try { - context.contentResolver.query(taskUri, null, selection, null, "dueDate ASC")?.use { cursor -> - val titleIdx = cursor.getColumnIndex("title") - - if (cursor.count == 0) { - // views.setTextViewText(eventViews[0], "No active tasks found") - // views.setViewVisibility(eventViews[0], android.view.View.VISIBLE) - // views.setTextColor(eventViews[0], secondaryColor) - // Just hide all - for (viewId in eventViews) { - views.setViewVisibility(viewId, android.view.View.GONE) - } - return - } - - var i = 0 - while (cursor.moveToNext() && i < eventViews.size) { - // Manual Filtering: Provider might ignore selection - val compIdx = cursor.getColumnIndex("completed") - val delIdx = cursor.getColumnIndex("deleted") - val dueIdx = cursor.getColumnIndex("dueDate") - val completed = if (compIdx >= 0) cursor.getString(compIdx) else null - val deleted = if (delIdx >= 0) cursor.getString(delIdx) else null - val dueMillis = if (dueIdx >= 0) cursor.getLong(dueIdx) else 0L - - val isCompleted = completed != null && completed != "0" - val isDeleted = deleted != null && deleted != "0" - - if (isCompleted || isDeleted) { - continue - } - - if (titleIdx != -1) { - val title = cursor.getString(titleIdx) ?: "No Title" - - var dueSuffix = "" - if (dueMillis > 0) { - val dueDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(dueMillis), ZoneId.systemDefault()).toLocalDate() - val today = LocalDate.now() - val tomorrow = today.plusDays(1) - - if (dueDate.isBefore(today)) { - dueSuffix = " (Overdue)" - } else if (dueDate.isEqual(today)) { - dueSuffix = " (Today)" - } else if (dueDate.isEqual(tomorrow)) { - dueSuffix = " (Tomorrow)" - } else { - val df = DateTimeFormatter.ofPattern("MMM d", Locale.getDefault()) - dueSuffix = " (${dueDate.format(df)})" - } - } - - val fullText = "• $title$dueSuffix" - val spannable = SpannableString(fullText) - val accentColor = context.getColor(R.color.widget_outline) - spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - - views.setTextViewText(eventViews[i], spannable) - views.setTextColor(eventViews[i], primaryColor) - views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp) - views.setViewVisibility(eventViews[i], android.view.View.VISIBLE) - - val taskIntent = context.packageManager.getLaunchIntentForPackage("org.tasks") - if (taskIntent != null) { - val taskPendingIntent = PendingIntent.getActivity(context, 1000 + i, taskIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - views.setOnClickPendingIntent(eventViews[i], taskPendingIntent) - } - i++ - } - } - - for (j in i until eventViews.size) { - views.setViewVisibility(eventViews[j], android.view.View.GONE) - } - return - } - } catch (e: Exception) { - // Fail silently or log + if (tasks.isEmpty()) { for (viewId in eventViews) { - views.setViewVisibility(viewId, android.view.View.GONE) + views.setViewVisibility(viewId, android.view.View.GONE) } + return + } + + for (i in tasks.indices) { + val task = tasks[i] + val dueSuffix = formatDueSuffix(task.dueMillis) + val fullText = "• ${task.title}$dueSuffix" + val spannable = SpannableString(fullText) + val accentColor = context.getColor(R.color.widget_outline) + spannable.setSpan(ForegroundColorSpan(accentColor), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + + views.setTextViewText(eventViews[i], spannable) + views.setTextColor(eventViews[i], primaryColor) + views.setTextViewTextSize(eventViews[i], android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp) + views.setViewVisibility(eventViews[i], android.view.View.VISIBLE) + + val taskIntent = context.packageManager.getLaunchIntentForPackage("org.tasks") + if (taskIntent != null) { + val taskPendingIntent = PendingIntent.getActivity(context, 1000 + i, taskIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + views.setOnClickPendingIntent(eventViews[i], taskPendingIntent) + } + } + + for (j in tasks.size until eventViews.size) { + views.setViewVisibility(eventViews[j], android.view.View.GONE) } - - // If we reached here (query null?), show generic message - // views.setTextViewText(eventViews[0], "Query Failed") - // views.setViewVisibility(eventViews[0], android.view.View.VISIBLE) } private fun loadWorldClock(views: RemoteViews, textSizeSp: Float, textColor: Int, zoneIdStr: String, is12Hour: Boolean) { @@ -1238,7 +1249,7 @@ class AwidgetProvider : AppWidgetProvider() { } } - private fun updateStorageStats(context: Context, views: RemoteViews) { + private fun updateStorageStats(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) { try { val path = android.os.Environment.getDataDirectory() val stat = android.os.StatFs(path.path) @@ -1250,7 +1261,6 @@ class AwidgetProvider : AppWidgetProvider() { val span = android.text.SpannableString("$gbStr GB") span.setSpan(android.text.style.RelativeSizeSpan(0.5f), gbStr.length, gbStr.length + 3, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) // GB - val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) if (prefs.getBoolean("bold_storage", false)) { span.setSpan(android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, span.length, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } @@ -1261,9 +1271,8 @@ class AwidgetProvider : AppWidgetProvider() { } } - private fun loadStepCount(context: Context, views: RemoteViews) { + private fun loadStepCount(context: Context, views: RemoteViews, prefs: android.content.SharedPreferences) { try { - val prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) val totalSteps = prefs.getFloat("last_total_steps", 0f) val baselineSteps = prefs.getFloat("step_baseline", 0f) @@ -1285,12 +1294,9 @@ class AwidgetProvider : AppWidgetProvider() { private fun getBestIntent(context: Context, packages: List, fallback: Intent): Intent { val pm = context.packageManager for (pkg in packages) { - try { - val intent = pm.getLaunchIntentForPackage(pkg) - if (intent != null) { - return intent - } - } catch (e: Exception) { + val intent = pm.getLaunchIntentForPackage(pkg) + if (intent != null) { + return intent } } return fallback diff --git a/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt b/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt new file mode 100644 index 0000000..39a60a2 --- /dev/null +++ b/app/src/main/java/com/leanbitlab/lwidget/ColorResolver.kt @@ -0,0 +1,51 @@ +package com.leanbitlab.lwidget + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import android.os.Build + +object ColorResolver { + fun resolveColor( + context: Context, + prefs: SharedPreferences, + useDynamicColors: Boolean, + idx: Int, + isPrimary: Boolean, + isLight: Boolean, + sdkInt: Int = Build.VERSION.SDK_INT + ): Int { + // When dynamic colors is on, always use dynamic palette regardless of saved color index + if (useDynamicColors && sdkInt >= Build.VERSION_CODES.S) { + return if (isPrimary) { + // High contrast accent for time & battery + context.getColor(if (isLight) android.R.color.system_accent1_800 else android.R.color.system_accent1_50) + } else { + // Muted neutral for secondary items (temp, data, storage, steps) + context.getColor(if (isLight) android.R.color.system_neutral2_600 else android.R.color.system_neutral2_300) + } + } + return when (idx) { + 0 -> { // Default + if (isPrimary) { + if (isLight) context.getColor(R.color.widget_text_light) else Color.WHITE + } else { + if (isLight) context.getColor(R.color.widget_text_secondary_light) else Color.parseColor("#CCFFFFFF") + } + } + 1 -> if (sdkInt >= Build.VERSION_CODES.S) { + context.getColor(android.R.color.system_accent1_500) + } else { + Color.CYAN + } + 2 -> { + val prefix = if (isPrimary) "text_color_primary" else "text_color_secondary" + val r = prefs.getInt("${prefix}_r", 255) + val g = prefs.getInt("${prefix}_g", 255) + val b = prefs.getInt("${prefix}_b", 255) + Color.rgb(r, g, b) + } + else -> if (isPrimary) Color.WHITE else Color.parseColor("#CCFFFFFF") + } + } +} diff --git a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt index 097cf74..2509b5b 100644 --- a/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt +++ b/app/src/main/java/com/leanbitlab/lwidget/MainActivity.kt @@ -193,7 +193,7 @@ class MainActivity : AppCompatActivity() { } // Check Tasks - if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, "org.tasks.permission.READ_TASKS") != PackageManager.PERMISSION_GRANTED) { + if (prefs.getBoolean("show_tasks", false) && ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) { prefs.edit().putBoolean("show_tasks", false).apply() findViewById(R.id.row_tasks_toggle).findViewById(R.id.row_switch).isChecked = false findViewById(R.id.row_tasks_size).visibility = View.GONE @@ -739,8 +739,25 @@ class MainActivity : AppCompatActivity() { val timeFormatOptions = listOf(getString(R.string.format_12h), getString(R.string.format_24h)) val colorOptions = listOf(getString(R.string.color_default), getString(R.string.color_system_accent), getString(R.string.color_custom)) + setupTimeSection(timeFormatOptions) + setupNextAlarmSection() + setupWorldClockSection(zoneIds) + setupDateSection(dateFormatOptions) + setupBatterySection() + setupTempSection() + setupWeatherSection() + setupDataUsageSection() + setupStorageSection() + setupStepsSection() + setupScreenTimeSection() + setupKeepAliveSection() + setupEventsAndTasksSections() + setupThemeSection(colorOptions) + } + + private fun setupTimeSection(timeFormatOptions: List) { // Time - val timeSwitch = bindFoldedSection( + bindFoldedSection( R.id.header_time, R.drawable.ic_time, getString(R.string.section_time), R.id.content_time, R.id.row_time_toggle, "show_time", true, @@ -748,7 +765,8 @@ class MainActivity : AppCompatActivity() { selectorRowId = R.id.row_time_format, selectorOptions = timeFormatOptions, prefSelectorKey = "time_format_idx", defSelectorIdx = 0, isContent = true ) - + } + private fun setupNextAlarmSection() { // Next Alarm bindFoldedSection( R.id.header_next_alarm, R.drawable.ic_alarm, getString(R.string.section_next_alarm), @@ -757,9 +775,10 @@ class MainActivity : AppCompatActivity() { sizeRowId = R.id.row_next_alarm_size, prefSizeKey = "size_next_alarm", defSize = 14f, minSize = 10f, maxSize = 24f, isContent = true ) - + } + private fun setupWorldClockSection(zoneIds: List) { // World Clock - val worldClockSwitch = bindFoldedSection( + bindFoldedSection( R.id.header_world_clock, R.drawable.ic_world, getString(R.string.section_world_clock), R.id.content_world_clock, R.id.row_world_clock_toggle, "show_world_clock", false, @@ -767,7 +786,8 @@ class MainActivity : AppCompatActivity() { isContent = true ) bindTimezoneSearch(R.id.row_world_clock_zone, zoneIds, "world_clock_zone_str", "UTC") - + } + private fun setupDateSection(dateFormatOptions: List) { // Date bindFoldedSection( R.id.header_date, R.drawable.ic_date, getString(R.string.section_date), @@ -777,9 +797,10 @@ class MainActivity : AppCompatActivity() { selectorRowId = R.id.row_date_format, selectorOptions = dateFormatOptions, prefSelectorKey = "date_format_idx", defSelectorIdx = 0, isContent = true ) - + } + private fun setupBatterySection() { // Battery - val batterySwitch = bindFoldedSection( + bindFoldedSection( R.id.header_battery, R.drawable.ic_battery, getString(R.string.section_battery), R.id.content_battery, R.id.row_battery_toggle, "show_battery", true, @@ -787,7 +808,8 @@ class MainActivity : AppCompatActivity() { isContent = true ).also { it.tag = "battery" } bindToggle(R.id.row_battery_bold, "Bold Text", "bold_battery", false) - + } + private fun setupTempSection() { // Temp bindFoldedSection( R.id.header_temp, R.drawable.ic_temp, getString(R.string.section_temp), @@ -797,7 +819,8 @@ class MainActivity : AppCompatActivity() { isContent = true ).also { it.tag = "temp" } bindToggle(R.id.row_temp_bold, "Bold Text", "bold_temp", false) - + } + private fun setupWeatherSection() { // Weather val weatherSwitch = bindFoldedSection( R.id.header_weather, R.drawable.ic_weather, getString(R.string.section_weather_condition), @@ -853,7 +876,8 @@ class MainActivity : AppCompatActivity() { .show() } } - + } + private fun setupDataUsageSection() { // Data Usage val dataSwitch = bindFoldedSection( R.id.header_data, R.drawable.ic_data, getString(R.string.section_data_usage), @@ -889,7 +913,8 @@ class MainActivity : AppCompatActivity() { updateToggleAvailability() checkAllPermissions() } - + } + private fun setupStorageSection() { // Storage bindFoldedSection( R.id.header_storage, R.drawable.ic_storage, getString(R.string.section_storage), @@ -899,7 +924,8 @@ class MainActivity : AppCompatActivity() { isContent = true ).also { it.tag = "storage" } bindToggle(R.id.row_storage_bold, "Bold Text", "bold_storage", false) - + } + private fun setupStepsSection() { // Steps val stepsSwitch = bindFoldedSection( R.id.header_steps, R.drawable.ic_steps, getString(R.string.section_steps), @@ -953,7 +979,8 @@ class MainActivity : AppCompatActivity() { updateToggleAvailability() checkAllPermissions() } - + } + private fun setupScreenTimeSection() { // Screen Time val screenTimeSwitch = bindFoldedSection( R.id.header_screen_time, R.drawable.ic_time, getString(R.string.section_screen_time), @@ -989,7 +1016,8 @@ class MainActivity : AppCompatActivity() { updateToggleAvailability() checkAllPermissions() } - + } + private fun setupKeepAliveSection() { // Keep Alive val keepAliveSwitch = bindFoldedSection( R.id.header_keep_alive, R.drawable.ic_alarm, getString(R.string.section_keep_alive), @@ -1022,7 +1050,8 @@ class MainActivity : AppCompatActivity() { else { stopService(serviceIntent) } updateWidget() } - + } + private fun setupEventsAndTasksSections() { // Events val eventsSwitch = bindFoldedSection( R.id.header_events, R.drawable.ic_events, getString(R.string.section_events), @@ -1065,7 +1094,6 @@ class MainActivity : AppCompatActivity() { checkAllPermissions() } } - tasksSwitch.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { if (!isAppInstalled("org.tasks")) { @@ -1083,8 +1111,8 @@ class MainActivity : AppCompatActivity() { }.show() return@setOnCheckedChangeListener } - if (ContextCompat.checkSelfPermission(this, "org.tasks.permission.READ_TASKS") != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf("org.tasks.permission.READ_TASKS"), 101) + if (ContextCompat.checkSelfPermission(this, AwidgetProvider.PERMISSION_READ_TASKS_ORG) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(AwidgetProvider.PERMISSION_READ_TASKS_ORG), 101) } if (checkLimit()) { eventsSwitch.isChecked = false @@ -1102,7 +1130,8 @@ class MainActivity : AppCompatActivity() { checkAllPermissions() } } - + } + private fun setupThemeSection(colorOptions: List) { // ===== THEME ===== // Use bindFoldedSection for the main card (top-level accordion), not bindNestedCard accordionViews["section_appearance_expanded"] = findViewById(R.id.content_appearance) diff --git a/app/src/test/java/com/leanbitlab/lwidget/AwidgetProviderTest.kt b/app/src/test/java/com/leanbitlab/lwidget/AwidgetProviderTest.kt new file mode 100644 index 0000000..8daec76 --- /dev/null +++ b/app/src/test/java/com/leanbitlab/lwidget/AwidgetProviderTest.kt @@ -0,0 +1,242 @@ +package com.leanbitlab.lwidget + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class AwidgetProviderTest { + + @Mock + private lateinit var context: Context + + @Mock + private lateinit var prefs: SharedPreferences + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `resolveColor dynamic colors ON SDK S primary light`() { + `when`(context.getColor(android.R.color.system_accent1_800)).thenReturn(1001) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = true, + idx = 0, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + assertEquals(1001, color) + } + + @Test + fun `resolveColor dynamic colors ON SDK S primary dark`() { + `when`(context.getColor(android.R.color.system_accent1_50)).thenReturn(1002) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = true, + idx = 0, + isPrimary = true, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + assertEquals(1002, color) + } + + @Test + fun `resolveColor dynamic colors ON SDK S secondary light`() { + `when`(context.getColor(android.R.color.system_neutral2_600)).thenReturn(1003) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = true, + idx = 0, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + assertEquals(1003, color) + } + + @Test + fun `resolveColor dynamic colors ON SDK S secondary dark`() { + `when`(context.getColor(android.R.color.system_neutral2_300)).thenReturn(1004) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = true, + idx = 0, + isPrimary = false, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + assertEquals(1004, color) + } + + @Test + fun `resolveColor idx 0 primary light`() { + `when`(context.getColor(R.color.widget_text_light)).thenReturn(2001) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 0, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(2001, color) + } + + @Test + fun `resolveColor idx 0 primary dark`() { + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 0, + isPrimary = true, + isLight = false, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(Color.WHITE, color) + } + + @Test + fun `resolveColor idx 0 secondary light`() { + `when`(context.getColor(R.color.widget_text_secondary_light)).thenReturn(2002) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 0, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(2002, color) + } + + @Test + fun `resolveColor idx 0 secondary dark`() { + val expectedColor = Color.parseColor("#CCFFFFFF") + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 0, + isPrimary = false, + isLight = false, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(expectedColor, color) + } + + @Test + fun `resolveColor idx 1 SDK S`() { + `when`(context.getColor(android.R.color.system_accent1_500)).thenReturn(3001) + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 1, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + assertEquals(3001, color) + } + + @Test + fun `resolveColor idx 1 SDK R`() { + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 1, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(Color.CYAN, color) + } + + @Test + fun `resolveColor idx 2 primary`() { + `when`(prefs.getInt("text_color_primary_r", 255)).thenReturn(255) + `when`(prefs.getInt("text_color_primary_g", 255)).thenReturn(0) + `when`(prefs.getInt("text_color_primary_b", 255)).thenReturn(0) + + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 2, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(Color.rgb(255, 0, 0), color) + } + + @Test + fun `resolveColor idx 2 secondary`() { + `when`(prefs.getInt("text_color_secondary_r", 255)).thenReturn(0) + `when`(prefs.getInt("text_color_secondary_g", 255)).thenReturn(255) + `when`(prefs.getInt("text_color_secondary_b", 255)).thenReturn(0) + + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 2, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(Color.rgb(0, 255, 0), color) + } + + @Test + fun `resolveColor idx else primary`() { + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 99, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(Color.WHITE, color) + } + + @Test + fun `resolveColor idx else secondary`() { + val expectedColor = Color.parseColor("#CCFFFFFF") + val color = AwidgetProvider.resolveColor( + context = context, + prefs = prefs, + useDynamicColors = false, + idx = 99, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + assertEquals(expectedColor, color) + } +} diff --git a/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt b/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt new file mode 100644 index 0000000..f4a9a27 --- /dev/null +++ b/app/src/test/java/com/leanbitlab/lwidget/ColorResolverTest.kt @@ -0,0 +1,287 @@ +package com.leanbitlab.lwidget + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class ColorResolverTest { + + @Test + fun testResolveColor_DynamicOn_Primary_Light_SdkS() { + val mockContext = mock { + on { getColor(android.R.color.system_accent1_800) } doReturn 0x112233 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = true, + idx = 0, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x112233, result) + } + + @Test + fun testResolveColor_DynamicOn_Primary_Dark_SdkS() { + val mockContext = mock { + on { getColor(android.R.color.system_accent1_50) } doReturn 0x223344 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = true, + idx = 0, + isPrimary = true, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x223344, result) + } + + @Test + fun testResolveColor_DynamicOn_Secondary_Light_SdkS() { + val mockContext = mock { + on { getColor(android.R.color.system_neutral2_600) } doReturn 0x334455 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = true, + idx = 0, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x334455, result) + } + + @Test + fun testResolveColor_DynamicOn_Secondary_Dark_SdkS() { + val mockContext = mock { + on { getColor(android.R.color.system_neutral2_300) } doReturn 0x445566 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = true, + idx = 0, + isPrimary = false, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x445566, result) + } + + @Test + fun testResolveColor_DynamicOn_ButOldSdk() { + // Fallback to idx=0 default + val mockContext = mock { + on { getColor(R.color.widget_text_light) } doReturn 0x556677 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = true, // but SDK is old + idx = 0, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + + assertEquals(0x556677, result) + } + + @Test + fun testResolveColor_DefaultIdx0_Primary_Light() { + val mockContext = mock { + on { getColor(R.color.widget_text_light) } doReturn 0x667788 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 0, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x667788, result) + } + + @Test + fun testResolveColor_DefaultIdx0_Primary_Dark() { + val mockContext = mock() + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 0, + isPrimary = true, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(Color.WHITE, result) + } + + @Test + fun testResolveColor_DefaultIdx0_Secondary_Light() { + val mockContext = mock { + on { getColor(R.color.widget_text_secondary_light) } doReturn 0x778899 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 0, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x778899, result) + } + + @Test + fun testResolveColor_DefaultIdx0_Secondary_Dark() { + val mockContext = mock() + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 0, + isPrimary = false, + isLight = false, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(Color.parseColor("#CCFFFFFF"), result) + } + + @Test + fun testResolveColor_Idx1_SdkS() { + val mockContext = mock { + on { getColor(android.R.color.system_accent1_500) } doReturn 0x889900 + } + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 1, + isPrimary = true, // shouldn't matter for idx 1 + isLight = true, // shouldn't matter for idx 1 + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(0x889900, result) + } + + @Test + fun testResolveColor_Idx1_OldSdk() { + val mockContext = mock() + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 1, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.R + ) + + assertEquals(Color.CYAN, result) + } + + @Test + fun testResolveColor_Idx2_CustomColor() { + val mockContext = mock() + val mockPrefs = mock { + on { getInt("text_color_primary_r", 255) } doReturn 100 + on { getInt("text_color_primary_g", 255) } doReturn 150 + on { getInt("text_color_primary_b", 255) } doReturn 200 + } + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 2, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(Color.rgb(100, 150, 200), result) + } + + @Test + fun testResolveColor_ElseIdx_Primary() { + val mockContext = mock() + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 99, + isPrimary = true, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(Color.WHITE, result) + } + + @Test + fun testResolveColor_ElseIdx_Secondary() { + val mockContext = mock() + val mockPrefs = mock() + + val result = ColorResolver.resolveColor( + context = mockContext, + prefs = mockPrefs, + useDynamicColors = false, + idx = 99, + isPrimary = false, + isLight = true, + sdkInt = Build.VERSION_CODES.S + ) + + assertEquals(Color.parseColor("#CCFFFFFF"), result) + } +} diff --git a/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt b/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt new file mode 100644 index 0000000..efdbf96 --- /dev/null +++ b/app/src/test/java/com/leanbitlab/lwidget/StepCounterServiceTest.kt @@ -0,0 +1,145 @@ +package com.leanbitlab.lwidget + +import android.content.Context +import android.content.SharedPreferences +import android.hardware.SensorEvent +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class StepCounterServiceTest { + + private lateinit var service: StepCounterService + private lateinit var prefs: SharedPreferences + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + prefs = context.getSharedPreferences("com.leanbitlab.lwidget.PREFS", Context.MODE_PRIVATE) + prefs.edit().clear().apply() + + service = Robolectric.buildService(StepCounterService::class.java).create().get() + } + + private fun createMockSensorEvent(steps: Float): SensorEvent { + val constructor = SensorEvent::class.java.declaredConstructors.first { it.parameterCount == 1 } + constructor.isAccessible = true + val event = constructor.newInstance(1) as SensorEvent + event.values[0] = steps + return event + } + + @Test + fun testOnSensorChanged_hardwareRebooted() { + // Setup initial state: previous total was 1000, baseline was 200 + // Meaning the user took 800 steps (1000 - 200) + prefs.edit() + .putFloat("last_total_steps", 1000f) + .putFloat("step_baseline", 200f) + .putString("step_date", LocalDate.now().toString()) + .apply() + + // Hardware rebooted, now sensor says 50 steps + val event = createMockSensorEvent(50f) + service.onSensorChanged(event) + + // New baseline should be: 50 - (1000 - 200) = 50 - 800 = -750 + assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(50f, prefs.getFloat("last_total_steps", 0f), 0.001f) + } + + @Test + fun testOnSensorChanged_hardwareRebootedMultipleTimes() { + // Initial state + prefs.edit() + .putFloat("last_total_steps", 1000f) + .putFloat("step_baseline", 200f) + .putString("step_date", LocalDate.now().toString()) + .apply() + + // First reboot, sensor goes from 1000 -> 50 + service.onSensorChanged(createMockSensorEvent(50f)) + + // Expected: baseline = 50 - (1000 - 200) = -750 + assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(50f, prefs.getFloat("last_total_steps", 0f), 0.001f) + + // Steps increase from 50 to 150 + service.onSensorChanged(createMockSensorEvent(150f)) + + // Expected: baseline remains -750, last_total_steps = 150 + assertEquals(-750f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(150f, prefs.getFloat("last_total_steps", 0f), 0.001f) + + // Second reboot, sensor goes from 150 -> 20 + service.onSensorChanged(createMockSensorEvent(20f)) + + // Expected: baseline = 20 - (150 - (-750)) = 20 - 900 = -880 + assertEquals(-880f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(20f, prefs.getFloat("last_total_steps", 0f), 0.001f) + } + + @Test + fun testOnSensorChanged_dailyReset() { + // Setup state for yesterday + val yesterday = LocalDate.now().minusDays(1).toString() + prefs.edit() + .putString("step_date", yesterday) + .putFloat("last_total_steps", 500f) + .putFloat("step_baseline", 100f) + .apply() + + val event = createMockSensorEvent(600f) + service.onSensorChanged(event) + + // Expected: Should update date to today and set baseline to current total + assertEquals(LocalDate.now().toString(), prefs.getString("step_date", "")) + assertEquals(600f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(600f, prefs.getFloat("last_total_steps", 0f), 0.001f) + } + + @Test + fun testOnSensorChanged_normalStepIncrease() { + // Normal state today + prefs.edit() + .putString("step_date", LocalDate.now().toString()) + .putFloat("last_total_steps", 1000f) + .putFloat("step_baseline", 200f) + .apply() + + // Step increases to 1050 + val event = createMockSensorEvent(1050f) + service.onSensorChanged(event) + + // Baseline shouldn't change + assertEquals(200f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertEquals(1050f, prefs.getFloat("last_total_steps", 0f), 0.001f) + } + + @Test + fun testOnSensorChanged_nullEvent() { + // Setup initial state + prefs.edit() + .putString("step_date", LocalDate.now().toString()) + .putFloat("last_total_steps", 1000f) + .putFloat("step_baseline", 200f) + .putBoolean("was_called", false) // marker to check if prefs was edited + .apply() + + service.onSensorChanged(null) + + // Ensure nothing was updated or changed + assertEquals(1000f, prefs.getFloat("last_total_steps", 0f), 0.001f) + assertEquals(200f, prefs.getFloat("step_baseline", 0f), 0.001f) + assertFalse(prefs.getBoolean("was_called", true)) + } +} diff --git a/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt b/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt new file mode 100644 index 0000000..14a7a31 --- /dev/null +++ b/app/src/test/java/com/leanbitlab/lwidget/weather/BreezyWeatherFetcherTest.kt @@ -0,0 +1,76 @@ +package com.leanbitlab.lwidget.weather + +import android.content.Context +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BreezyWeatherFetcherTest { + + private lateinit var mockContext: Context + private lateinit var mockPrefs: SharedPreferences + + private val prefsName = "lwidget_breezy_weather_data" + private val keyWeatherJson = "weather_json" + + @Before + fun setup() { + mockPrefs = mock() + mockContext = mock { + on { getSharedPreferences(prefsName, Context.MODE_PRIVATE) } doReturn mockPrefs + } + } + + @Test + fun `fetchLocalWeather with valid json returns parsed data`() { + val validJson = """ + { + "timestamp": 1678886400, + "location": "Berlin", + "currentTemp": 15 + } + """.trimIndent() + + whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(validJson) + + val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext) + + assertEquals(1678886400, result?.timestamp) + assertEquals("Berlin", result?.location) + assertEquals(15, result?.currentTemp) + } + + @Test + fun `fetchLocalWeather with null json returns null`() { + whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(null) + + val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext) + + assertNull(result) + } + + @Test + fun `fetchLocalWeather with empty json returns null`() { + whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn("") + + val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext) + + assertNull(result) + } + + @Test + fun `fetchLocalWeather with malformed json returns null`() { + val malformedJson = """{ "timestamp": 1678886400, "location": """ + + whenever(mockPrefs.getString(keyWeatherJson, null)).thenReturn(malformedJson) + + val result = BreezyWeatherFetcher.fetchLocalWeather(mockContext) + + assertNull(result) + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..c97e991 --- /dev/null +++ b/plan.md @@ -0,0 +1,23 @@ +1. **Refactor `resolveColor`:** + - Move the nested `resolveColor` function inside `AwidgetProvider.updateAppWidget` to the companion object (or an internal class level method) to make it accessible for testing. + - Update its signature to accept its dependencies (`context`, `prefs`, `useDynamicColors`, `idx`, `isPrimary`, `isLight`, and optionally `sdkInt` for easier testing of branching behavior based on Android version) as arguments. + - Update the original call sites inside `updateAppWidget` to use the refactored function. + +2. **Add Testing Dependencies:** + - Update `app/build.gradle.kts` to include `testImplementation` dependencies for `junit:junit:4.13.2` and `org.mockito:mockito-core:5.+` (or `org.mockito.kotlin:mockito-kotlin`). + +3. **Write Unit Tests:** + - Create a new file `app/src/test/java/com/leanbitlab/lwidget/AwidgetProviderTest.kt`. + - Add comprehensive tests covering various scenarios of `resolveColor` including: + - Dynamic colors behavior for SDK >= S (Android 12) vs older versions. + - Primary vs secondary text color logic. + - Light vs dark theme logic. + - Default color index (`idx == 0`). + - System accent color index (`idx == 1`). + - Custom RGB preference colors (`idx == 2`). + +4. **Complete pre-commit steps:** + - Complete pre-commit steps to make sure proper testing, verifications, reviews, and reflections are done. + +5. **Submit the change.** + - Once all tests pass, I will submit the change with a descriptive commit message.