diff --git a/android/app/build.gradle b/android/app/build.gradle
index a0b2df4258..e5126e2f94 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -122,6 +122,9 @@ dependencies {
}
testImplementation "junit:junit:4.13.2"
+ // Real org.json for JVM unit tests (android.jar only ships stubs); used by
+ // WidgetDataTest to lock the widget_data blob contract.
+ testImplementation "org.json:json:20240303"
androidTestImplementation "androidx.test:rules:1.6.1"
androidTestImplementation "com.android.support.test:runner:1.0.2"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 15f66f5fc2..acd5a34b54 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -174,5 +174,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt
index 63b416966c..96efce9322 100644
--- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt
@@ -32,6 +32,7 @@ import com.superproductivity.superproductivity.webview.WebViewCompatibilityCheck
import com.superproductivity.superproductivity.webview.WebViewRecovery
import com.superproductivity.superproductivity.widget.ShareIntentQueue
import com.superproductivity.superproductivity.widget.StartupOverlayManager
+import com.superproductivity.superproductivity.widget.TaskListWidgetProvider
import com.superproductivity.plugins.webdavhttp.WebDavHttpPlugin
import org.json.JSONObject
@@ -63,6 +64,7 @@ class CapacitorMainActivity : BridgeActivity() {
private var isTimerCompleteReceiverRegistered = false
private var isForegroundServiceFailureReceiverRegistered = false
+ private var isWidgetDoneDrainReceiverRegistered = false
private val storageHelper =
SimpleStorageHelper(this) // for scoped storage permission management on Android 10+
@@ -77,6 +79,17 @@ class CapacitorMainActivity : BridgeActivity() {
}
}
+ private val widgetDoneDrainReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == TaskListWidgetProvider.ACTION_WIDGET_DONE_DRAIN) {
+ // Contentless drain signal: Angular pulls the queued IDs itself via
+ // getWidgetDoneQueue(), so there is a single delivery path and no
+ // task data crosses the string-interpolated JS bridge.
+ callJSInterfaceFunctionIfExists("next", "onWidgetDoneDrainRequest$")
+ }
+ }
+ }
+
private val foregroundServiceFailureReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ForegroundServiceFailure.ACTION) {
@@ -224,6 +237,11 @@ class CapacitorMainActivity : BridgeActivity() {
IntentFilter(ForegroundServiceFailure.ACTION)
)
isForegroundServiceFailureReceiverRegistered = true
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ widgetDoneDrainReceiver,
+ IntentFilter(TaskListWidgetProvider.ACTION_WIDGET_DONE_DRAIN)
+ )
+ isWidgetDoneDrainReceiverRegistered = true
// Show startup overlay for quick task entry while Angular loads.
// Only on fresh cold start — not on config-change recreation.
@@ -615,6 +633,10 @@ class CapacitorMainActivity : BridgeActivity() {
)
isForegroundServiceFailureReceiverRegistered = false
}
+ if (isWidgetDoneDrainReceiverRegistered) {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(widgetDoneDrainReceiver)
+ isWidgetDoneDrainReceiverRegistered = false
+ }
super.onDestroy()
}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt b/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt
index e4337f9f5b..e7da7e5088 100644
--- a/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/app/KeyValStore.kt
@@ -8,6 +8,11 @@ import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import com.superproductivity.superproductivity.App
+// NOTE: no per-call db.close() anywhere in this class. SQLiteOpenHelper caches one
+// connection for the process lifetime (the standard pattern); closing it per call
+// defeated that cache and would churn once the home screen widget started reading
+// `widget_data` alongside the JS-bridge writes. All access stays serialized via
+// @Synchronized on the App-level singleton.
class KeyValStore(private val context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
@@ -54,7 +59,6 @@ class KeyValStore(private val context: Context) :
values.put(KEY_CREATED_AT, System.currentTimeMillis())
row = db.replace(DATABASE_TABLE, null, values)
Log.v(TAG, "save db value size: " + value?.length)
- db.close()
}
return row
}
@@ -94,8 +98,6 @@ class KeyValStore(private val context: Context) :
// callers can surface "no backup" instead of an opaque invocation error.
Log.e(TAG, "get failed for key $newKey", e)
defaultValue
- } finally {
- database.close()
}
}
@@ -105,7 +107,6 @@ class KeyValStore(private val context: Context) :
if (db != null) {
db.delete(DATABASE_TABLE, null, null)
Log.v(TAG, "cleared db ")
- db.close()
}
}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt
index 7c69552db9..f05d152d49 100644
--- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt
@@ -26,6 +26,8 @@ import com.superproductivity.superproductivity.widget.ReminderDoneQueue
import com.superproductivity.superproductivity.widget.ReminderSnoozeQueue
import com.superproductivity.superproductivity.widget.ReminderTapQueue
import com.superproductivity.superproductivity.widget.ShareIntentQueue
+import com.superproductivity.superproductivity.widget.TaskListWidgetProvider
+import com.superproductivity.superproductivity.widget.WidgetDoneQueue
import com.superproductivity.superproductivity.widget.WidgetTaskQueue
import org.json.JSONObject
@@ -372,6 +374,26 @@ class JavaScriptInterface(
return WidgetTaskQueue.getAndClearQueue(activity)
}
+ /**
+ * Get pending done-state changes from the home screen widget and clear the
+ * queue. Returns a JSON object string `{taskId: targetIsDone}` or null if empty.
+ */
+ @Suppress("unused")
+ @JavascriptInterface
+ fun getWidgetDoneQueue(): String? {
+ return WidgetDoneQueue.getAndClear(activity)
+ }
+
+ /**
+ * Re-render the home screen widget from the current `widget_data` KeyValStore
+ * snapshot. Called by Angular after each snapshot push.
+ */
+ @Suppress("unused")
+ @JavascriptInterface
+ fun updateWidget() {
+ TaskListWidgetProvider.notifyDataChanged(activity)
+ }
+
/**
* Pull-based retrieval of pending share data persisted in SharedPreferences.
* Clears both SharedPreferences and in-memory pendingShareIntent to prevent duplicates.
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt
new file mode 100644
index 0000000000..620c8cae81
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetProvider.kt
@@ -0,0 +1,131 @@
+package com.superproductivity.superproductivity.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.widget.RemoteViews
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.superproductivity.superproductivity.CapacitorMainActivity
+import com.superproductivity.superproductivity.R
+
+/**
+ * Home screen widget listing today's tasks from the `widget_data` KeyValStore
+ * snapshot pushed by Angular. Checkbox taps enqueue the task ID in
+ * [WidgetDoneQueue]; Angular drains the queue (instantly via the local drain
+ * broadcast when alive, otherwise on next resume/cold start).
+ */
+class TaskListWidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ updateWidget(context, appWidgetManager, appWidgetId)
+ }
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ if (intent.action != ACTION_CLICK) {
+ return
+ }
+ // A collection view has a single PendingIntent template, so both row
+ // outcomes arrive here; the fill-in extras decide which one was tapped.
+ val taskId = intent.getStringExtra(EXTRA_TASK_ID)
+ when {
+ taskId != null -> {
+ // Target state computed at render time from the DISPLAYED state
+ // (incl. pending overlay), so repeated taps toggle back and forth.
+ val setDone = intent.getBooleanExtra(EXTRA_SET_DONE, true)
+ Log.d(TAG, "Toggle done from widget: taskId=$taskId setDone=$setDone")
+ WidgetDoneQueue.setTarget(context, taskId, setDone)
+ // Re-render so the pending-done overlay shows the checked box
+ notifyDataChanged(context)
+ // Contentless "drain now" signal for a live app; Angular always
+ // pulls the IDs from the queue itself (single delivery path).
+ LocalBroadcastManager.getInstance(context)
+ .sendBroadcast(Intent(ACTION_WIDGET_DONE_DRAIN))
+ }
+
+ intent.getBooleanExtra(EXTRA_OPEN_APP, false) -> {
+ try {
+ context.startActivity(
+ Intent(context, CapacitorMainActivity::class.java).apply {
+ flags =
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ )
+ } catch (e: Exception) {
+ // Background-activity-launch restrictions may block this on some
+ // API levels/OEMs; the header tap (direct activity PendingIntent)
+ // remains as the reliable way in.
+ Log.w(TAG, "Failed to open app from widget row tap", e)
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "TaskListWidget"
+ const val ACTION_CLICK = "com.superproductivity.superproductivity.WIDGET_CLICK"
+ const val ACTION_WIDGET_DONE_DRAIN =
+ "com.superproductivity.superproductivity.WIDGET_DONE_DRAIN"
+ const val EXTRA_TASK_ID = "WIDGET_TASK_ID"
+ const val EXTRA_SET_DONE = "WIDGET_SET_DONE"
+ const val EXTRA_OPEN_APP = "WIDGET_OPEN_APP"
+
+ fun notifyDataChanged(context: Context) {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val widgetIds = appWidgetManager.getAppWidgetIds(
+ ComponentName(context, TaskListWidgetProvider::class.java)
+ )
+ appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.widget_task_list)
+ }
+
+ private fun updateWidget(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int
+ ) {
+ val views = RemoteViews(context.packageName, R.layout.widget_task_list)
+
+ val serviceIntent = Intent(context, TaskListWidgetService::class.java).apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
+ }
+ views.setRemoteAdapter(R.id.widget_task_list, serviceIntent)
+ views.setEmptyView(R.id.widget_task_list, R.id.widget_empty)
+
+ // Single template for all row clicks (explicit component — needs no
+ // manifest intent-filter entry). MUTABLE is required for fill-ins.
+ val clickIntent = Intent(context, TaskListWidgetProvider::class.java).apply {
+ action = ACTION_CLICK
+ }
+ val clickPendingIntent = PendingIntent.getBroadcast(
+ context, 0, clickIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ views.setPendingIntentTemplate(R.id.widget_task_list, clickPendingIntent)
+
+ // Header tap → open app
+ val openAppIntent = Intent(context, CapacitorMainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ val openAppPendingIntent = PendingIntent.getActivity(
+ context, 0, openAppIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_header, openAppPendingIntent)
+ views.setOnClickPendingIntent(R.id.widget_empty, openAppPendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt
new file mode 100644
index 0000000000..0a153135af
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/TaskListWidgetService.kt
@@ -0,0 +1,102 @@
+package com.superproductivity.superproductivity.widget
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.util.Log
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import com.superproductivity.superproductivity.App
+import com.superproductivity.superproductivity.R
+
+class TaskListWidgetService : RemoteViewsService() {
+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
+ return TaskListRemoteViewsFactory(applicationContext)
+ }
+}
+
+private class TaskListRemoteViewsFactory(
+ private val context: Context
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private var tasks: List = emptyList()
+
+ override fun onCreate() {}
+
+ override fun onDataSetChanged() {
+ tasks = try {
+ val json = (context.applicationContext as App).keyValStore
+ .get(WidgetData.KEYVAL_KEY, "{}")
+ WidgetData.parse(json, WidgetDoneQueue.peek(context)).take(MAX_TASKS)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse widget data", e)
+ emptyList()
+ }
+ }
+
+ override fun onDestroy() {
+ tasks = emptyList()
+ }
+
+ override fun getCount(): Int = tasks.size
+
+ override fun getViewAt(position: Int): RemoteViews {
+ val rv = RemoteViews(context.packageName, R.layout.widget_task_row)
+
+ if (position >= tasks.size) {
+ return rv
+ }
+
+ val task = tasks[position]
+ rv.setTextViewText(R.id.widget_task_title, task.title)
+ rv.setTextColor(
+ R.id.widget_task_title,
+ context.getColor(if (task.isDone) R.color.widget_ink_muted else R.color.widget_ink)
+ )
+ rv.setImageViewResource(
+ R.id.widget_done_checkbox,
+ if (task.isDone) R.drawable.ic_widget_check_done else R.drawable.ic_widget_check_outline
+ )
+
+ // Project dot: tint with the project color, hide entirely for
+ // project-less tasks instead of showing a meaningless default color
+ val color = task.projectColor?.let {
+ try {
+ Color.parseColor(it)
+ } catch (e: Exception) {
+ null
+ }
+ }
+ if (color != null) {
+ rv.setViewVisibility(R.id.widget_project_dot, android.view.View.VISIBLE)
+ rv.setInt(R.id.widget_project_dot, "setColorFilter", color)
+ } else {
+ rv.setViewVisibility(R.id.widget_project_dot, android.view.View.GONE)
+ }
+
+ // Checkbox toggles to the opposite of the DISPLAYED state; anywhere else
+ // on the row opens the app.
+ rv.setOnClickFillInIntent(
+ R.id.widget_done_checkbox,
+ Intent()
+ .putExtra(TaskListWidgetProvider.EXTRA_TASK_ID, task.id)
+ .putExtra(TaskListWidgetProvider.EXTRA_SET_DONE, !task.isDone)
+ )
+ rv.setOnClickFillInIntent(
+ R.id.widget_task_row,
+ Intent().putExtra(TaskListWidgetProvider.EXTRA_OPEN_APP, true)
+ )
+
+ return rv
+ }
+
+ override fun getLoadingView(): RemoteViews? = null
+ override fun getViewTypeCount(): Int = 1
+ override fun getItemId(position: Int): Long = position.toLong()
+ override fun hasStableIds(): Boolean = false
+
+ companion object {
+ private const val TAG = "TaskListWidget"
+ private const val MAX_TASKS = 20
+ }
+}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt
new file mode 100644
index 0000000000..1d3abc6f4b
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt
@@ -0,0 +1,58 @@
+package com.superproductivity.superproductivity.widget
+
+import org.json.JSONObject
+
+data class WidgetTask(
+ val id: String,
+ val title: String,
+ val isDone: Boolean,
+ val projectColor: String?
+)
+
+/**
+ * Native end of the `widget_data` KeyValStore contract. The writer is Angular's
+ * WidgetDataService; the blob shape is defined by AndroidWidgetData in
+ * src/app/features/android/android-widget.model.ts — keep both ends in sync and
+ * bump `v` on breaking changes.
+ */
+object WidgetData {
+ const val KEYVAL_KEY = "widget_data"
+ private const val SUPPORTED_VERSION = 1
+
+ /**
+ * @param pendingDoneTargets per-task done-state targets queued via
+ * [WidgetDoneQueue] but not yet applied by Angular — overlaid so a checkbox
+ * tap is reflected immediately even while the app process is dead.
+ */
+ fun parse(
+ json: String,
+ pendingDoneTargets: Map = emptyMap()
+ ): List {
+ val root = JSONObject(json)
+ if (root.optInt("v", -1) != SUPPORTED_VERSION) {
+ return emptyList()
+ }
+ val tasksArray = root.optJSONArray("tasks") ?: return emptyList()
+ val projectColors = root.optJSONObject("projectColors")
+ val result = mutableListOf()
+ for (i in 0 until tasksArray.length()) {
+ val task = tasksArray.getJSONObject(i)
+ val id = task.getString("id")
+ // isNull guard: optString maps JSON null to the literal string "null"
+ val projectId =
+ if (task.isNull("projectId")) null else task.optString("projectId", null)
+ val color = projectId?.let { pId ->
+ projectColors?.takeIf { !it.isNull(pId) }?.optString(pId, null)
+ }
+ result.add(
+ WidgetTask(
+ id = id,
+ title = task.getString("title"),
+ isDone = pendingDoneTargets[id] ?: task.optBoolean("isDone", false),
+ projectColor = color
+ )
+ )
+ }
+ return result
+ }
+}
diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt
new file mode 100644
index 0000000000..48843a3e50
--- /dev/null
+++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetDoneQueue.kt
@@ -0,0 +1,63 @@
+package com.superproductivity.superproductivity.widget
+
+import android.content.Context
+import android.content.SharedPreferences
+import org.json.JSONObject
+
+/**
+ * SharedPreferences-backed queue of pending done-state changes from widget
+ * checkbox taps, stored as a JSON object map `{taskId: targetIsDone}` — last tap
+ * per task wins, so tapping a task done and back to undone before the app runs
+ * collapses into a single (or no-op) change.
+ *
+ * Angular is the only consumer (JavaScriptInterface.getWidgetDoneQueue) and the
+ * only writer of the `widget_data` snapshot; the widget itself only ever peek()s
+ * so pending taps render correctly without native code mutating the blob.
+ */
+object WidgetDoneQueue {
+ private const val PREFS_NAME = "SuperProductivityWidgetDone"
+ private const val KEY_DONE_TASKS = "WIDGET_DONE_TASK_IDS"
+
+ private fun getPrefs(context: Context): SharedPreferences {
+ return context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ @Synchronized
+ fun setTarget(context: Context, taskId: String, isDone: Boolean) {
+ val prefs = getPrefs(context)
+ val map = prefs.getString(KEY_DONE_TASKS, null)?.let {
+ try {
+ JSONObject(it)
+ } catch (e: Exception) {
+ JSONObject()
+ }
+ } ?: JSONObject()
+ map.put(taskId, isDone)
+ // commit (not apply): the enqueue runs in a short-lived broadcast, the process
+ // may be killed right after — the tap must survive that.
+ prefs.edit().putString(KEY_DONE_TASKS, map.toString()).commit()
+ }
+
+ /** Non-clearing read used to overlay pending done state at widget render time. */
+ @Synchronized
+ fun peek(context: Context): Map {
+ val data = getPrefs(context).getString(KEY_DONE_TASKS, null) ?: return emptyMap()
+ return try {
+ val map = JSONObject(data)
+ map.keys().asSequence().associateWith { map.getBoolean(it) }
+ } catch (e: Exception) {
+ emptyMap()
+ }
+ }
+
+ /** @return JSON object string `{taskId: targetIsDone}`, or null if empty. */
+ @Synchronized
+ fun getAndClear(context: Context): String? {
+ val prefs = getPrefs(context)
+ val data = prefs.getString(KEY_DONE_TASKS, null)
+ if (data != null) {
+ prefs.edit().remove(KEY_DONE_TASKS).commit()
+ }
+ return data
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_widget_check_done.xml b/android/app/src/main/res/drawable/ic_widget_check_done.xml
new file mode 100644
index 0000000000..c589bf60c3
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_check_done.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_check_outline.xml b/android/app/src/main/res/drawable/ic_widget_check_outline.xml
new file mode 100644
index 0000000000..3967ac30cb
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_check_outline.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_bg.xml b/android/app/src/main/res/drawable/widget_bg.xml
new file mode 100644
index 0000000000..eb6ff4c59e
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_project_dot.xml b/android/app/src/main/res/drawable/widget_project_dot.xml
new file mode 100644
index 0000000000..8dc456c3bb
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_project_dot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_task_list.xml b/android/app/src/main/res/layout/widget_task_list.xml
new file mode 100644
index 0000000000..ac5ae8a2e3
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_task_list.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_task_row.xml b/android/app/src/main/res/layout/widget_task_row.xml
new file mode 100644
index 0000000000..e49bce625c
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_task_row.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
index db7621cf0a..1919e0913f 100644
--- a/android/app/src/main/res/values-night/colors.xml
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -3,4 +3,11 @@
#131314
#131314
#131314
+
+
+ #F5131314
+ #E6E6E6
+ #A6EBEBEB
+ #1AFFFFFF
+ #A05DB1
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 691dac2c42..bb7b5f136d 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -8,4 +8,13 @@
#f8f8f7
#424242
+
+ #F5F8F8F7
+ #DE000000
+ #8A000000
+ #1F000000
+ #8B4A9D
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 73c7fc919e..43aa21be36 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -28,6 +28,13 @@
Content received, creating task\u2026
+
+ Today\'s Tasks
+ Today
+ Shows today\'s tasks with quick done action
+ No tasks for today
+ Toggle task done
+
Add task
Add a task while loading\u2026
diff --git a/android/app/src/main/res/xml/appwidget_info.xml b/android/app/src/main/res/xml/appwidget_info.xml
new file mode 100644
index 0000000000..555425d6e1
--- /dev/null
+++ b/android/app/src/main/res/xml/appwidget_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt
new file mode 100644
index 0000000000..111edf80ae
--- /dev/null
+++ b/android/app/src/test/java/com/superproductivity/superproductivity/widget/WidgetDataTest.kt
@@ -0,0 +1,75 @@
+package com.superproductivity.superproductivity.widget
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Locks the native end of the `widget_data` v:1 contract. The writer-side shape is
+ * locked by android-widget.selectors.spec.ts — if one changes, the other must too.
+ */
+class WidgetDataTest {
+
+ private val blob =
+ """
+ {
+ "v": 1,
+ "tasks": [
+ {"id": "t1", "title": "Task one", "isDone": false, "projectId": "p1"},
+ {"id": "t2", "title": "Task two", "isDone": true},
+ {"id": "t3", "title": "Task three", "isDone": false, "projectId": null}
+ ],
+ "projectColors": {"p1": "#ff0000"}
+ }
+ """.trimIndent()
+
+ @Test
+ fun parsesTasksWithProjectColors() {
+ val tasks = WidgetData.parse(blob)
+ assertEquals(3, tasks.size)
+ assertEquals(WidgetTask("t1", "Task one", false, "#ff0000"), tasks[0])
+ assertEquals(WidgetTask("t2", "Task two", true, null), tasks[1])
+ }
+
+ @Test
+ fun jsonNullProjectIdDoesNotBecomeStringNull() {
+ // org.json's optString maps JSON null to the literal string "null"
+ val tasks = WidgetData.parse(blob)
+ assertNull(tasks[2].projectColor)
+ }
+
+ @Test
+ fun overlaysPendingDoneTargets() {
+ val tasks = WidgetData.parse(blob, pendingDoneTargets = mapOf("t1" to true))
+ assertTrue(tasks[0].isDone)
+ assertTrue(tasks[1].isDone)
+ assertEquals(false, tasks[2].isDone)
+ }
+
+ @Test
+ fun overlaysPendingUndoneTargets() {
+ // t2 is done in the blob but has a pending "mark undone" tap
+ val tasks = WidgetData.parse(blob, pendingDoneTargets = mapOf("t2" to false))
+ assertEquals(false, tasks[1].isDone)
+ }
+
+ @Test
+ fun unknownVersionParsesToEmpty() {
+ assertTrue(WidgetData.parse("""{"v": 2, "tasks": [{"id": "x"}]}""").isEmpty())
+ assertTrue(WidgetData.parse("""{"tasks": []}""").isEmpty())
+ }
+
+ @Test
+ fun emptyBlobParsesToEmpty() {
+ assertTrue(WidgetData.parse("{}").isEmpty())
+ assertTrue(WidgetData.parse("""{"v": 1}""").isEmpty())
+ }
+
+ @Test
+ fun missingColorFallsBackToNull() {
+ val json =
+ """{"v":1,"tasks":[{"id":"a","title":"A","isDone":false,"projectId":"px"}],"projectColors":{}}"""
+ assertNull(WidgetData.parse(json)[0].projectColor)
+ }
+}
diff --git a/docs/plans/2026-07-03-android-home-screen-widget.md b/docs/plans/2026-07-03-android-home-screen-widget.md
new file mode 100644
index 0000000000..c025c59885
--- /dev/null
+++ b/docs/plans/2026-07-03-android-home-screen-widget.md
@@ -0,0 +1,80 @@
+# Android home screen widget (revival of PR #7124)
+
+Implements #3818. Based on ilvez's POC (PR #7124, closed) plus the 2026-06-08 review
+punch-list. Re-implemented on current master (the PR's April base has drifted heavily).
+
+## Architecture (unchanged from POC — confirmed sound)
+
+- **Snapshot bridge:** Angular pushes a compact JSON snapshot of today's tasks into the
+ existing native `KeyValStore` (SQLite) under key `widget_data`. The native
+ `RemoteViewsFactory` reads it. No new storage mechanism.
+- **Done actions:** `SharedPreferences`-backed `WidgetDoneQueue` (mirrors
+ `WidgetTaskQueue` / `ReminderDoneQueue` patterns), drained by Angular.
+
+## Improvements over the POC (mapped to the punch-list)
+
+1. **Title tap opens app (bug fix).** A collection has one `setPendingIntentTemplate`;
+ the POC's second fill-in intent silently fell into the done-template. Fix: one
+ broadcast template, fill-ins carry either `EXTRA_TASK_ID` (→ done) or
+ `EXTRA_OPEN_APP` (→ `startActivity`), branch in `onReceive`.
+2. **Manifest hardening.** Custom actions removed from the exported receiver's
+ intent-filter (explicit-component PendingIntents don't need filter entries; listing
+ them let any app mark tasks done / launch us). Only `APPWIDGET_UPDATE` remains.
+3. **Queue-only done path.** Widget tap → enqueue + widget refresh + contentless
+ "drain now" LocalBroadcast. Angular always reads via `getWidgetDoneQueue()`
+ (get-and-clear). Single delivery path; no double `setDone()`; retires the
+ `'$sanitizedId'` string-interpolated JS call.
+4. **Single-writer blob (replaces the proposed ts-guard).** Native never writes
+ `widget_data`. For instant checkbox feedback while the app is dead, the factory
+ overlays `isDone=true` for IDs currently in `WidgetDoneQueue` at render time
+ (`peek()`, non-clearing). The Angular-vs-native write race disappears structurally;
+ `markDoneInWidgetData` is deleted.
+5. **Memoized snapshot selector.** `selectAndroidWidgetData` projects
+ todayIds + task entities + project colors into the exact blob shape. Project color
+ changes now propagate (POC pulled colors in but excluded them from the distinct key).
+ Dedupe happens in `WidgetDataService` via a last-pushed-JSON cache, so all trigger
+ paths share it.
+6. **Post-sync freshness push (new — gap found in review).** The hydration-guard filter
+ drops all emissions during remote-op apply and nothing re-emits after, so the widget
+ would miss synced changes until the next local edit. Added push on the
+ `isSyncInProgress$` falling edge. (Push-on-pause kept as belt-and-braces.)
+7. **Typed contract.** `AndroidWidgetData` interface (TS) + `WidgetData.kt` parser as
+ the two named ends of the `v:1` contract, locked by a golden-shape unit test on the
+ serializer and a JVM parse test on the Kotlin side. (typia writer-side assert was
+ considered and skipped: the object is constructed from typed state, so an assert is
+ tautological, and a throw would leave the widget stale — worse than pushing.)
+8. **Drain hygiene.** Dedupe IDs, skip missing/already-done tasks (no duplicate ops, no
+ op-log noise from stale queue entries), gate on initial data load. One aggregated
+ translated snack (`T`/en.json) instead of one hardcoded snack per task.
+9. **KeyValStore per-call `db.close()` removal** (correct SQLiteOpenHelper pattern,
+ avoids churn from widget reads) — redone against the current chunked-read code.
+ All access is `@Synchronized` on the `App`-level singleton, so widget-vs-bridge
+ concurrency stays serialized. Backup-ring round trip must be manually verified on
+ device (no Robolectric in the project).
+10. Kotlin JSON parsing extracted to `WidgetData.kt` (single parse site, JVM-testable
+ via `org.json:json` test dep). `optString("projectId", null)` JSON-NULL→"null"
+ footgun avoided via `isNull()` checks; Angular omits `projectId` when absent.
+
+## Known limitations (deliberate, documented)
+
+- Widget reflects the app's last known state; cross-client freshness while the app is
+ dead is phase 2 (WorkManager + SuperSync).
+- Day rollover while the app process is dead shows yesterday's list until next open
+ (native can't recompute "today"; selector handles rollover whenever JS is alive).
+- Hardcoded dark styling; Jetpack Glance / Material You is a follow-up view-layer swap.
+- Widget chrome strings are native `strings.xml` (English) — accepted for v1.
+- No task creation / undo from widget.
+
+## Files
+
+Native: `widget/{TaskListWidgetProvider,TaskListWidgetService,WidgetData,WidgetDoneQueue}.kt`,
+`CapacitorMainActivity.kt` (drain receiver), `webview/JavaScriptInterface.kt`
+(`getWidgetDoneQueue`, `updateWidget`), `app/KeyValStore.kt`, manifest, layouts,
+`xml/appwidget_info.xml`, `values/strings.xml`, `build.gradle` (test dep),
+`test/.../widget/WidgetDataTest.kt`.
+
+Angular: `features/android/android-widget.model.ts`,
+`features/android/store/android-widget.selectors.ts` (+spec),
+`features/android/widget-data.service.ts` (+spec),
+`features/android/store/android-widget.effects.ts` (+spec),
+`android-interface.ts`, `root-store/feature-stores.module.ts`, `en.json` (+`npm run int`).
diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts
index 8693684ec0..63793dbf4c 100644
--- a/src/app/features/android/android-interface.ts
+++ b/src/app/features/android/android-interface.ts
@@ -100,6 +100,13 @@ export interface AndroidInterface {
// Widget task queue - get queued tasks from home screen widget
getWidgetTaskQueue?(): string | null;
+ // Widget done queue - get pending done-state changes from the home screen
+ // widget as a JSON object string `{taskId: targetIsDone}` and clear the queue
+ getWidgetDoneQueue?(): string | null;
+
+ // Re-render the home screen widget from the current widget_data snapshot
+ updateWidget?(): void;
+
// Startup overlay
getStartupOverlayPartialText?(): string | null;
hideStartupOverlay?(): void;
@@ -136,6 +143,11 @@ export interface AndroidInterface {
onReminderSnooze$: ReplaySubject<{ taskId: string; newRemindAt: number }>; // emits snooze events
getReminderSnoozeQueue?(): string | null;
+ // Contentless "drain now" signal after a widget done-checkbox tap while the app
+ // is alive; the queued IDs are always pulled via getWidgetDoneQueue() (cold
+ // start/resume drains are triggered by onResume$ instead)
+ onWidgetDoneDrainRequest$: Subject;
+
// Background sync credential bridge (for WorkManager-based reminder cancellation)
setSuperSyncCredentials?(baseUrl: string, accessToken: string): void;
clearSuperSyncCredentials?(): void;
@@ -170,6 +182,7 @@ if (IS_ANDROID_WEB_VIEW) {
androidInterface.onReminderTap$ = new ReplaySubject(5);
androidInterface.onReminderDone$ = new ReplaySubject(20);
androidInterface.onReminderSnooze$ = new ReplaySubject(20);
+ androidInterface.onWidgetDoneDrainRequest$ = new Subject();
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
diff --git a/src/app/features/android/android-widget.model.ts b/src/app/features/android/android-widget.model.ts
new file mode 100644
index 0000000000..3cbe8484e9
--- /dev/null
+++ b/src/app/features/android/android-widget.model.ts
@@ -0,0 +1,26 @@
+/**
+ * Contract for the `widget_data` KeyValStore blob consumed by the native Android
+ * home screen widget. The native reader is
+ * `android/app/src/main/java/com/superproductivity/superproductivity/widget/WidgetData.kt`
+ * — keep both ends in sync. Bump `v` on breaking changes; the native side renders
+ * unknown versions as an empty widget instead of mis-parsing them.
+ *
+ * Angular is the ONLY writer of this blob. Pending widget done-taps are overlaid
+ * natively at render time from WidgetDoneQueue, never written into the blob.
+ */
+export const ANDROID_WIDGET_DATA_KEY = 'widget_data';
+
+export interface AndroidWidgetTask {
+ id: string;
+ title: string;
+ isDone: boolean;
+ // omitted (not null) when the task has no project — org.json's optString maps
+ // JSON null to the literal string "null"
+ projectId?: string;
+}
+
+export interface AndroidWidgetData {
+ v: 1;
+ tasks: AndroidWidgetTask[];
+ projectColors: { [projectId: string]: string };
+}
diff --git a/src/app/features/android/store/android-widget.effects.spec.ts b/src/app/features/android/store/android-widget.effects.spec.ts
new file mode 100644
index 0000000000..f1e793ef99
--- /dev/null
+++ b/src/app/features/android/store/android-widget.effects.spec.ts
@@ -0,0 +1,66 @@
+import { getTaskDoneChangesToApply } from './android-widget.effects';
+import { Task } from '../../tasks/task.model';
+import { Dictionary } from '@ngrx/entity';
+
+/**
+ * The effects themselves are gated by IS_ANDROID_WEB_VIEW (false in tests), so
+ * we test the drain decision logic directly (repo convention, see
+ * android-sync-bridge.effects.spec.ts).
+ */
+describe('AndroidWidgetEffects - getTaskDoneChangesToApply', () => {
+ const entities = (...tasks: { id: string; isDone?: boolean }[]): Dictionary =>
+ Object.fromEntries(
+ tasks.map((t) => [t.id, { id: t.id, isDone: !!t.isDone } as Task]),
+ );
+
+ it('should mark undone tasks done', () => {
+ expect(
+ getTaskDoneChangesToApply(
+ '{"a":true,"b":true}',
+ entities({ id: 'a' }, { id: 'b' }),
+ ),
+ ).toEqual([
+ { id: 'a', isDone: true },
+ { id: 'b', isDone: true },
+ ]);
+ });
+
+ it('should mark done tasks undone', () => {
+ expect(
+ getTaskDoneChangesToApply('{"a":false}', entities({ id: 'a', isDone: true })),
+ ).toEqual([{ id: 'a', isDone: false }]);
+ });
+
+ it('should skip tasks deleted since the tap', () => {
+ expect(
+ getTaskDoneChangesToApply('{"gone":true,"a":true}', entities({ id: 'a' })),
+ ).toEqual([{ id: 'a', isDone: true }]);
+ });
+
+ it('should skip tasks already in the target state (no redundant update ops)', () => {
+ expect(
+ getTaskDoneChangesToApply(
+ '{"a":true,"b":true}',
+ entities({ id: 'a', isDone: true }, { id: 'b' }),
+ ),
+ ).toEqual([{ id: 'b', isDone: true }]);
+ });
+
+ it('should treat a done→undone round trip as a no-op', () => {
+ // last-wins map: tapping done then undone before the app runs → target false
+ expect(getTaskDoneChangesToApply('{"a":false}', entities({ id: 'a' }))).toEqual([]);
+ });
+
+ it('should return empty for invalid JSON', () => {
+ expect(getTaskDoneChangesToApply('not json', entities({ id: 'a' }))).toEqual([]);
+ });
+
+ it('should return empty for non-object JSON', () => {
+ expect(getTaskDoneChangesToApply('["a"]', entities({ id: 'a' }))).toEqual([]);
+ expect(getTaskDoneChangesToApply('null', entities({ id: 'a' }))).toEqual([]);
+ });
+
+ it('should skip non-boolean target values', () => {
+ expect(getTaskDoneChangesToApply('{"a":"true"}', entities({ id: 'a' }))).toEqual([]);
+ });
+});
diff --git a/src/app/features/android/store/android-widget.effects.ts b/src/app/features/android/store/android-widget.effects.ts
new file mode 100644
index 0000000000..cfe94730e5
--- /dev/null
+++ b/src/app/features/android/store/android-widget.effects.ts
@@ -0,0 +1,151 @@
+import { inject, Injectable } from '@angular/core';
+import { createEffect } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import { merge } from 'rxjs';
+import {
+ concatMap,
+ debounceTime,
+ filter,
+ first,
+ pairwise,
+ switchMap,
+ tap,
+} from 'rxjs/operators';
+import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
+import { androidInterface } from '../android-interface';
+import { WidgetDataService } from '../widget-data.service';
+import { TaskService } from '../../tasks/task.service';
+import { SnackService } from '../../../core/snack/snack.service';
+import { DroidLog } from '../../../core/log';
+import { T } from '../../../t.const';
+import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
+import { DataInitStateService } from '../../../core/data-init/data-init-state.service';
+import { selectAndroidWidgetData } from './android-widget.selectors';
+import { selectTaskEntities } from '../../tasks/store/task.selectors';
+import { Dictionary } from '@ngrx/entity';
+import { Task } from '../../tasks/task.model';
+
+/**
+ * Decides which queued widget checkbox taps become setDone()/setUnDone() calls.
+ * The queue is a last-wins map `{taskId: targetIsDone}`; missing tasks (deleted
+ * since the tap) and tasks already in the target state are skipped so stale
+ * queue entries never produce redundant update ops. Exported for direct
+ * testing — the effect itself is gated by IS_ANDROID_WEB_VIEW.
+ */
+export const getTaskDoneChangesToApply = (
+ queueJson: string,
+ taskEntities: Dictionary,
+): { id: string; isDone: boolean }[] => {
+ let targets: unknown;
+ try {
+ targets = JSON.parse(queueJson);
+ } catch (e) {
+ DroidLog.err('Failed to parse widget done queue', e);
+ return [];
+ }
+ if (typeof targets !== 'object' || targets === null || Array.isArray(targets)) {
+ return [];
+ }
+ return Object.entries(targets as Record)
+ .filter(([id, isDone]) => {
+ const task = taskEntities[id];
+ return typeof isDone === 'boolean' && !!task && task.isDone !== isDone;
+ })
+ .map(([id, isDone]) => ({ id, isDone: isDone as boolean }));
+};
+
+@Injectable()
+export class AndroidWidgetEffects {
+ private _store = inject(Store);
+ private _widgetDataService = inject(WidgetDataService);
+ private _taskService = inject(TaskService);
+ private _snackService = inject(SnackService);
+ private _hydrationState = inject(HydrationStateService);
+ private _dataInitState = inject(DataInitStateService);
+
+ // The selector emission is only a change trigger; pushCurrent() re-reads the
+ // store at push time (fresher than debounce-stale data) and dedupes itself.
+ pushOnStateChange$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ this._store.select(selectAndroidWidgetData).pipe(
+ filter(() => !this._hydrationState.isApplyingRemoteOps()),
+ debounceTime(500),
+ tap(() => this._widgetDataService.pushCurrent()),
+ ),
+ { dispatch: false },
+ );
+
+ // The guard above drops ALL emissions while remote ops are applied and nothing
+ // re-emits afterwards, so without this the widget would miss synced changes
+ // until the next local edit. Push once whenever the sync window closes.
+ pushOnSyncWindowEnd$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ this._hydrationState.isInSyncWindow$.pipe(
+ pairwise(),
+ filter(([wasInWindow, isInWindow]) => wasInWindow && !isInWindow),
+ tap(() => this._widgetDataService.pushCurrent()),
+ ),
+ { dispatch: false },
+ );
+
+ // Last chance to hand the freshest state to the widget before the WebView may
+ // be frozen or killed in the background — deliberately not debounced.
+ pushOnPause$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ androidInterface.onPause$.pipe(tap(() => this._widgetDataService.pushCurrent())),
+ { dispatch: false },
+ );
+
+ // Single delivery path for widget done-taps: cold start and background→
+ // foreground are covered by onResume$ (ReplaySubject(1)), taps while the app
+ // is alive by the contentless native drain signal. The queue read is
+ // get-and-clear, so overlapping triggers drain at most once.
+ drainWidgetDoneQueue$ =
+ IS_ANDROID_WEB_VIEW &&
+ createEffect(
+ () =>
+ merge(
+ androidInterface.onResume$,
+ androidInterface.onWidgetDoneDrainRequest$,
+ ).pipe(
+ concatMap(() =>
+ this._dataInitState.isAllDataLoadedInitially$.pipe(
+ first(),
+ switchMap(() => this._store.select(selectTaskEntities).pipe(first())),
+ ),
+ ),
+ tap((taskEntities) => this._drainDoneQueue(taskEntities)),
+ ),
+ { dispatch: false },
+ );
+
+ private _drainDoneQueue(taskEntities: Dictionary): void {
+ const queueJson = androidInterface.getWidgetDoneQueue?.();
+ if (!queueJson) {
+ return;
+ }
+ const changes = getTaskDoneChangesToApply(queueJson, taskEntities);
+ for (const change of changes) {
+ if (change.isDone) {
+ this._taskService.setDone(change.id);
+ } else {
+ this._taskService.setUnDone(change.id);
+ }
+ }
+ DroidLog.log('Drained widget done queue', { changeCount: changes.length });
+
+ if (changes.length > 0) {
+ this._snackService.open({
+ type: 'SUCCESS',
+ msg: T.F.ANDROID.WIDGET_TASKS_UPDATED,
+ translateParams: { count: changes.length },
+ });
+ }
+ }
+}
diff --git a/src/app/features/android/store/android-widget.selectors.spec.ts b/src/app/features/android/store/android-widget.selectors.spec.ts
new file mode 100644
index 0000000000..8f64cfa4c8
--- /dev/null
+++ b/src/app/features/android/store/android-widget.selectors.spec.ts
@@ -0,0 +1,91 @@
+import { selectAndroidWidgetData } from './android-widget.selectors';
+import { Task } from '../../tasks/task.model';
+import { Project } from '../../project/project.model';
+
+describe('selectAndroidWidgetData', () => {
+ const task = (id: string, partial: Partial = {}): Task =>
+ ({
+ id,
+ title: `Task ${id}`,
+ isDone: false,
+ projectId: undefined,
+ ...partial,
+ }) as Task;
+
+ const project = (id: string, primary?: string): Project =>
+ ({
+ id,
+ title: `Project ${id}`,
+ theme: primary ? { primary } : {},
+ }) as Project;
+
+ const projectState = (projects: Project[]): any => ({
+ ids: projects.map((p) => p.id),
+ entities: Object.fromEntries(projects.map((p) => [p.id, p])),
+ });
+
+ it('should project today tasks in order with project colors', () => {
+ const result = selectAndroidWidgetData.projector(
+ ['t1', 't2'],
+ {
+ t1: task('t1', { title: 'Task one', projectId: 'p1' }),
+ t2: task('t2', { title: 'Task two', isDone: true }),
+ },
+ projectState([project('p1', '#ff0000')]),
+ );
+ expect(result).toEqual({
+ v: 1,
+ tasks: [
+ { id: 't1', title: 'Task one', isDone: false, projectId: 'p1' },
+ { id: 't2', title: 'Task two', isDone: true },
+ ],
+ projectColors: { p1: '#ff0000' },
+ });
+ });
+
+ it('should skip today ids without a task entity', () => {
+ const result = selectAndroidWidgetData.projector(
+ ['missing', 't1'],
+ { t1: task('t1') },
+ projectState([]),
+ );
+ expect(result.tasks.length).toBe(1);
+ expect(result.tasks[0].id).toBe('t1');
+ });
+
+ it('should omit projectId key entirely for project-less tasks (JSON null breaks the Kotlin parser contract)', () => {
+ const result = selectAndroidWidgetData.projector(
+ ['t1'],
+ { t1: task('t1', { projectId: undefined }) },
+ projectState([]),
+ );
+ expect('projectId' in result.tasks[0]).toBe(false);
+ });
+
+ it('should not include colors for projects without a theme primary', () => {
+ const result = selectAndroidWidgetData.projector(
+ ['t1'],
+ { t1: task('t1', { projectId: 'p1' }) },
+ projectState([project('p1')]),
+ );
+ expect(result.projectColors).toEqual({});
+ expect(result.tasks[0].projectId).toBe('p1');
+ });
+
+ it('should serialize to the exact v:1 blob shape consumed by WidgetData.kt (see WidgetDataTest.kt)', () => {
+ const result = selectAndroidWidgetData.projector(
+ ['t1', 't2'],
+ {
+ t1: task('t1', { title: 'Task one', projectId: 'p1' }),
+ t2: task('t2', { title: 'Task two', isDone: true }),
+ },
+ projectState([project('p1', '#ff0000')]),
+ );
+ expect(JSON.stringify(result)).toBe(
+ '{"v":1,"tasks":[' +
+ '{"id":"t1","title":"Task one","isDone":false,"projectId":"p1"},' +
+ '{"id":"t2","title":"Task two","isDone":true}],' +
+ '"projectColors":{"p1":"#ff0000"}}',
+ );
+ });
+});
diff --git a/src/app/features/android/store/android-widget.selectors.ts b/src/app/features/android/store/android-widget.selectors.ts
new file mode 100644
index 0000000000..e2ae443a8f
--- /dev/null
+++ b/src/app/features/android/store/android-widget.selectors.ts
@@ -0,0 +1,42 @@
+import { createSelector } from '@ngrx/store';
+import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors';
+import { selectTaskEntities } from '../../tasks/store/task.selectors';
+import { selectProjectFeatureState } from '../../project/store/project.selectors';
+import { AndroidWidgetData, AndroidWidgetTask } from '../android-widget.model';
+
+/**
+ * Projects today's tasks into the exact `widget_data` blob shape, so downstream
+ * consumers get referential stability from the selector memoization and cheap
+ * change detection via JSON comparison in WidgetDataService.
+ */
+export const selectAndroidWidgetData = createSelector(
+ selectTodayTaskIds,
+ selectTaskEntities,
+ selectProjectFeatureState,
+ (todayTaskIds, taskEntities, projectState): AndroidWidgetData => {
+ const tasks: AndroidWidgetTask[] = [];
+ const projectColors: { [projectId: string]: string } = {};
+
+ for (const taskId of todayTaskIds) {
+ const task = taskEntities[taskId];
+ if (!task) {
+ continue;
+ }
+ const widgetTask: AndroidWidgetTask = {
+ id: task.id,
+ title: task.title,
+ isDone: task.isDone,
+ };
+ if (task.projectId) {
+ widgetTask.projectId = task.projectId;
+ const color = projectState.entities[task.projectId]?.theme?.primary;
+ if (color) {
+ projectColors[task.projectId] = color;
+ }
+ }
+ tasks.push(widgetTask);
+ }
+
+ return { v: 1, tasks, projectColors };
+ },
+);
diff --git a/src/app/features/android/widget-data.service.ts b/src/app/features/android/widget-data.service.ts
new file mode 100644
index 0000000000..19305e491e
--- /dev/null
+++ b/src/app/features/android/widget-data.service.ts
@@ -0,0 +1,34 @@
+import { inject, Injectable } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { firstValueFrom } from 'rxjs';
+import { androidInterface } from './android-interface';
+import { ANDROID_WIDGET_DATA_KEY } from './android-widget.model';
+import { selectAndroidWidgetData } from './store/android-widget.selectors';
+import { DroidLog } from '../../core/log';
+
+/**
+ * Pushes the current today-task snapshot to the native KeyValStore for the home
+ * screen widget. Dedupes against the last successfully pushed blob so every
+ * trigger path (state change, pause, post-sync) can call it unconditionally.
+ */
+@Injectable({ providedIn: 'root' })
+export class WidgetDataService {
+ private _store = inject(Store);
+ private _lastPushedJson: string | null = null;
+
+ async pushCurrent(): Promise {
+ const data = await firstValueFrom(this._store.select(selectAndroidWidgetData));
+ const json = JSON.stringify(data);
+ if (json === this._lastPushedJson) {
+ return;
+ }
+ try {
+ await androidInterface.saveToDbWrapped(ANDROID_WIDGET_DATA_KEY, json);
+ androidInterface.updateWidget?.();
+ // only remember successful pushes, so a failed one is retried next trigger
+ this._lastPushedJson = json;
+ } catch (e) {
+ DroidLog.err('Failed to push widget data', e);
+ }
+ }
+}
diff --git a/src/app/root-store/feature-stores.module.ts b/src/app/root-store/feature-stores.module.ts
index e64d2fa24d..63ffc3cc73 100644
--- a/src/app/root-store/feature-stores.module.ts
+++ b/src/app/root-store/feature-stores.module.ts
@@ -69,6 +69,7 @@ import { AndroidEffects } from '../features/android/store/android.effects';
import { AndroidFocusModeEffects } from '../features/android/store/android-focus-mode.effects';
import { AndroidForegroundTrackingEffects } from '../features/android/store/android-foreground-tracking.effects';
import { AndroidSyncBridgeEffects } from '../features/android/store/android-sync-bridge.effects';
+import { AndroidWidgetEffects } from '../features/android/store/android-widget.effects';
import { MobileNotificationEffects } from '../features/mobile/store/mobile-notification.effects';
import { IS_IOS_NATIVE, IS_NATIVE_PLATFORM } from '../util/is-native-platform';
import { IosBackgroundTrackingEffects } from '../features/ios/store/ios-background-tracking.effects';
@@ -184,6 +185,7 @@ import {
AndroidFocusModeEffects,
AndroidForegroundTrackingEffects,
AndroidSyncBridgeEffects,
+ AndroidWidgetEffects,
]
: []),
]),
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index f2aed74848..004d284671 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -106,6 +106,7 @@ const T = {
FOREGROUND_SERVICE_START_FAILED_TRACKING:
'F.ANDROID.FOREGROUND_SERVICE_START_FAILED_TRACKING',
OPEN_NOTIFICATION_SETTINGS: 'F.ANDROID.OPEN_NOTIFICATION_SETTINGS',
+ WIDGET_TASKS_UPDATED: 'F.ANDROID.WIDGET_TASKS_UPDATED',
},
ATTACHMENT: {
LOCAL_FILE_UNAVAILABLE: 'F.ATTACHMENT.LOCAL_FILE_UNAVAILABLE',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 69d3079ed2..23b6a0f12c 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -102,7 +102,8 @@
"ANDROID": {
"FOREGROUND_SERVICE_START_FAILED_FOCUS": "Android blocked the persistent focus mode notification. The timer continues in the app, but background alerts may be unavailable. Check notification permission and battery/background restrictions.",
"FOREGROUND_SERVICE_START_FAILED_TRACKING": "Android blocked the persistent time tracking notification. Time is still tracked in the app, but background recovery may be unavailable. Check notification permission and battery/background restrictions.",
- "OPEN_NOTIFICATION_SETTINGS": "Open settings"
+ "OPEN_NOTIFICATION_SETTINGS": "Open settings",
+ "WIDGET_TASKS_UPDATED": "{{count}} task(s) updated from widget"
},
"ATTACHMENT": {
"LOCAL_FILE_UNAVAILABLE": "This attachment is only available on the device where it was added.",