Wave 1 (#1, #2): foreground timer service, background catch-up, alarms, native TTS, warning tone

This commit is contained in:
Lukas Holzner committed 2026-10-02 14:18:15 +02:00
1 parent fe9e75a4e7
commit bbfcbe74af
16 files changed
+920 -17

No files matched your search

+3 -1
View File
@@ -9,7 +9,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-text-to-speech')
implementation project(':capacitor-app')
implementation project(':capacitor-local-notifications')
}
+13
View File
@@ -33,9 +33,22 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<!-- Ongoing focus timer (#1) -->
<service
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Ongoing focus timer keeping step countdown alive while the app is in background." />
</service>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>
@@ -1,5 +1,13 @@
package app.mindlist.foss;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(TimerPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,62 @@
package app.mindlist.foss;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/** JS bridge for TimerService (#1). All methods are safe no-ops on web. */
@CapacitorPlugin(name = "TimerService")
public class TimerPlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
String title = call.getString("title", "Mindlist focus");
String text = call.getString("text", "");
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.putExtra(TimerService.EXTRA_TITLE, title);
i.putExtra(TimerService.EXTRA_TEXT, text);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
@PluginMethod
public void update(PluginCall call) {
String title = call.getString("title", "Mindlist focus");
String text = call.getString("text", "");
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.setAction(TimerService.ACTION_UPDATE);
i.putExtra(TimerService.EXTRA_TITLE, title);
i.putExtra(TimerService.EXTRA_TEXT, text);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
@PluginMethod
public void stop(PluginCall call) {
Context ctx = getContext();
Intent i = new Intent(ctx, TimerService.class);
i.setAction(TimerService.ACTION_STOP);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i);
} else {
ctx.startService(i);
}
call.resolve(new JSObject());
}
}
@@ -0,0 +1,103 @@
package app.mindlist.foss;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;
/**
* Ongoing foreground service that keeps the focus timer alive while the
* app is minimized or the phone is locked (#1). Shows a persistent
* notification with the current step + remaining time.
*/
public class TimerService extends Service {
public static final String CHANNEL_ID = "mindlist_timer";
public static final int NOTIF_ID = 1001;
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_TEXT = "text";
public static final String ACTION_UPDATE = "app.mindlist.foss.UPDATE_TIMER";
public static final String ACTION_STOP = "app.mindlist.foss.STOP_TIMER";
private String title = "Mindlist focus";
private String text = "";
@Override
public void onCreate() {
super.onCreate();
createChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
if (ACTION_STOP.equals(action)) {
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
return START_NOT_STICKY;
}
if (intent.hasExtra(EXTRA_TITLE)) title = intent.getStringExtra(EXTRA_TITLE);
if (intent.hasExtra(EXTRA_TEXT)) text = intent.getStringExtra(EXTRA_TEXT);
}
Notification notif = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif);
} else {
startForeground(NOTIF_ID, notif);
}
// NOT_STICKY: scheduled alarms (not the service) are the source of
// truth — a kill-restarted zombie with stale text is worse than none.
// Known gap: reboot drops pending alarms (no BOOT receiver yet).
return START_NOT_STICKY;
}
private void createChannel() {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) return;
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Focus timer", NotificationManager.IMPORTANCE_LOW);
ch.setDescription("Ongoing focus timer while steps run");
ch.setSound(null, null);
nm.createNotificationChannel(ch);
}
private Notification buildNotification() {
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(
this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent stop = new Intent(this, TimerService.class);
stop.setAction(ACTION_STOP);
PendingIntent stopPi = PendingIntent.getService(
this, 1, stop, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
int icon = getResources().getIdentifier("ic_stat_timer", "drawable", getPackageName());
if (icon == 0) icon = android.R.drawable.ic_dialog_info;
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(text)
.setStyle(new NotificationCompat.BigTextStyle().bigText(text))
.setSmallIcon(icon)
.setContentIntent(pi)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPi)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="512" android:viewportHeight="512">
<!-- monochrome silhouette of the v3 logo for status-bar icons -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 256 124 A 132 132 0 1 0 360 180"
android:strokeColor="#FFFFFF"
android:strokeWidth="36"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:fillColor="#FFFFFF"
android:pathData="M 210 270 l 34 34 l 64 -76"
android:strokeColor="#FFFFFF"
android:strokeWidth="42"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
+9
View File
@@ -1,3 +1,12 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-text-to-speech'
project(':capacitor-community-text-to-speech').projectDir = new File('../node_modules/@capacitor-community/text-to-speech/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')