mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(android): add better notifications and permanent notification for focus mode
This commit is contained in:
parent
ffa7122aea
commit
f7901ba47f
14 changed files with 973 additions and 47 deletions
|
|
@ -87,5 +87,25 @@
|
|||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="time_tracking" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.FocusModeForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="focus_timer" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.ReminderAlarmReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.ReminderActionReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.activity.addCallback
|
|||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.superproductivity.superproductivity.plugins.SafBridgePlugin
|
||||
import com.superproductivity.superproductivity.service.FocusModeForegroundService
|
||||
import com.superproductivity.superproductivity.service.TrackingForegroundService
|
||||
import com.superproductivity.superproductivity.util.printWebViewVersion
|
||||
import com.superproductivity.superproductivity.webview.JavaScriptInterface
|
||||
|
|
@ -158,6 +159,27 @@ class CapacitorMainActivity : BridgeActivity() {
|
|||
callJSInterfaceFunctionIfExists("next", "onMarkTaskDone$")
|
||||
return
|
||||
}
|
||||
// Handle focus mode notification actions
|
||||
FocusModeForegroundService.ACTION_PAUSE -> {
|
||||
Log.d("SP_FOCUS", "Pause action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusPause$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_RESUME -> {
|
||||
Log.d("SP_FOCUS", "Resume action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusResume$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_SKIP -> {
|
||||
Log.d("SP_FOCUS", "Skip action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusSkip$")
|
||||
return
|
||||
}
|
||||
FocusModeForegroundService.ACTION_COMPLETE -> {
|
||||
Log.d("SP_FOCUS", "Complete action received from focus mode notification")
|
||||
callJSInterfaceFunctionIfExists("next", "onFocusComplete$")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle share intent
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package com.superproductivity.superproductivity.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
|
||||
/**
|
||||
* Handles reminder snooze action in the background by simply rescheduling the alarm.
|
||||
* No app involvement needed - just dismiss notification and schedule new alarm.
|
||||
*/
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReminderActionReceiver"
|
||||
const val ACTION_SNOOZE = "com.superproductivity.REMINDER_SNOOZE"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
const val EXTRA_REMINDER_ID = "reminder_id"
|
||||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
|
||||
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_SNOOZE) return
|
||||
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
|
||||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
|
||||
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
|
||||
|
||||
// Dismiss the notification
|
||||
if (notificationId != -1) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
// Reschedule alarm for 10 minutes from now
|
||||
val newTriggerTime = System.currentTimeMillis() + SNOOZE_DURATION_MS
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
context,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
newTriggerTime
|
||||
)
|
||||
|
||||
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.superproductivity.superproductivity.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
|
||||
/**
|
||||
* Receives alarm broadcasts and shows reminder notifications.
|
||||
*/
|
||||
class ReminderAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "ReminderAlarmReceiver"
|
||||
const val ACTION_SHOW_REMINDER = "com.superproductivity.ACTION_SHOW_REMINDER"
|
||||
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||
const val EXTRA_REMINDER_ID = "reminder_id"
|
||||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_SHOW_REMINDER) return
|
||||
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||
val reminderId = intent.getStringExtra(EXTRA_REMINDER_ID) ?: return
|
||||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
|
||||
|
||||
ReminderNotificationHelper.showNotification(
|
||||
context,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
class FocusModeForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "FocusModeService"
|
||||
|
||||
const val ACTION_START = "com.superproductivity.ACTION_START_FOCUS"
|
||||
const val ACTION_STOP = "com.superproductivity.ACTION_STOP_FOCUS"
|
||||
const val ACTION_UPDATE = "com.superproductivity.ACTION_UPDATE_FOCUS"
|
||||
const val ACTION_PAUSE = "com.superproductivity.ACTION_PAUSE_FOCUS"
|
||||
const val ACTION_RESUME = "com.superproductivity.ACTION_RESUME_FOCUS"
|
||||
const val ACTION_SKIP = "com.superproductivity.ACTION_SKIP_FOCUS"
|
||||
const val ACTION_COMPLETE = "com.superproductivity.ACTION_COMPLETE_FOCUS"
|
||||
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_TASK_TITLE = "task_title"
|
||||
const val EXTRA_DURATION_MS = "duration_ms"
|
||||
const val EXTRA_REMAINING_MS = "remaining_ms"
|
||||
const val EXTRA_IS_BREAK = "is_break"
|
||||
const val EXTRA_IS_PAUSED = "is_paused"
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
}
|
||||
|
||||
private var title: String = ""
|
||||
private var taskTitle: String? = null
|
||||
private var durationMs: Long = 0
|
||||
private var remainingMs: Long = 0
|
||||
private var isBreak: Boolean = false
|
||||
private var isPaused: Boolean = false
|
||||
private var lastUpdateTimestamp: Long = 0
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isRunning && !isPaused) {
|
||||
// Update remaining time (countdown mode)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastUpdateTimestamp
|
||||
lastUpdateTimestamp = now
|
||||
|
||||
if (durationMs > 0) {
|
||||
// Countdown mode: decrease remaining time
|
||||
remainingMs = (remainingMs - elapsed).coerceAtLeast(0)
|
||||
} else {
|
||||
// Flowtime mode: increase elapsed time (remainingMs is actually elapsed)
|
||||
remainingMs += elapsed
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
FocusModeNotificationHelper.createChannel(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand: action=${intent?.action}")
|
||||
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
title = intent.getStringExtra(EXTRA_TITLE) ?: "Focus"
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE)
|
||||
durationMs = intent.getLongExtra(EXTRA_DURATION_MS, 0L)
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, 0L)
|
||||
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, false)
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false)
|
||||
|
||||
startFocusMode()
|
||||
}
|
||||
|
||||
ACTION_UPDATE -> {
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs)
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused)
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
ACTION_STOP -> {
|
||||
stopFocusMode()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Service restarted by system - we have no state to restore
|
||||
Log.d(TAG, "Service started without action, stopping")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startFocusMode() {
|
||||
Log.d(TAG, "Starting focus mode: title=$title, durationMs=$durationMs, remainingMs=$remainingMs, isBreak=$isBreak, isPaused=$isPaused")
|
||||
|
||||
isRunning = true
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Start foreground immediately to avoid ANR
|
||||
val notification = FocusModeNotificationHelper.buildNotification(
|
||||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
)
|
||||
startForeground(FocusModeNotificationHelper.NOTIFICATION_ID, notification)
|
||||
|
||||
// Start update loop if not paused
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
if (!isPaused) {
|
||||
handler.post(updateRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFocusMode() {
|
||||
Log.d(TAG, "Stopping focus mode")
|
||||
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
if (!isRunning) return
|
||||
|
||||
try {
|
||||
val notification = FocusModeNotificationHelper.buildNotification(
|
||||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
)
|
||||
NotificationManagerCompat.from(this).notify(
|
||||
FocusModeNotificationHelper.NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "No permission to post notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d(TAG, "Service destroyed")
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(TAG, "Task removed, stopping service")
|
||||
stopFocusMode()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.superproductivity.superproductivity.CapacitorMainActivity
|
||||
import com.superproductivity.superproductivity.R
|
||||
|
||||
object FocusModeNotificationHelper {
|
||||
const val CHANNEL_ID = "sp_focus_mode_channel"
|
||||
const val NOTIFICATION_ID = 1002
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Focus Mode",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows focus mode timer status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildNotification(
|
||||
context: Context,
|
||||
title: String,
|
||||
taskTitle: String?,
|
||||
remainingMs: Long,
|
||||
isPaused: Boolean,
|
||||
isBreak: Boolean
|
||||
): Notification {
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
10,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(buildTitle(title, taskTitle))
|
||||
.setContentText(formatDuration(remainingMs))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
// Add Pause/Resume action
|
||||
if (isPaused) {
|
||||
val resumeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_RESUME
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val resumePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
11,
|
||||
resumeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Resume", resumePendingIntent)
|
||||
} else {
|
||||
val pauseIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_PAUSE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pausePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
12,
|
||||
pauseIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Pause", pausePendingIntent)
|
||||
}
|
||||
|
||||
// Add Skip (for breaks) or Complete (for work sessions) action
|
||||
if (isBreak) {
|
||||
val skipIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_SKIP
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val skipPendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
13,
|
||||
skipIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Skip", skipPendingIntent)
|
||||
} else {
|
||||
val completeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_COMPLETE
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val completePendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
14,
|
||||
completeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
builder.addAction(0, "Complete", completePendingIntent)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildTitle(focusTitle: String, taskTitle: String?): String {
|
||||
return if (taskTitle.isNullOrBlank()) {
|
||||
focusTitle
|
||||
} else {
|
||||
"$focusTitle: $taskTitle"
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = ms / 1000
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return String.format("%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.superproductivity.superproductivity.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.superproductivity.superproductivity.CapacitorMainActivity
|
||||
import com.superproductivity.superproductivity.R
|
||||
import com.superproductivity.superproductivity.receiver.ReminderActionReceiver
|
||||
import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
|
||||
|
||||
/**
|
||||
* Simple helper for native reminder notifications.
|
||||
* Snooze works entirely in background (just reschedules alarm).
|
||||
* Tapping notification opens app.
|
||||
*/
|
||||
object ReminderNotificationHelper {
|
||||
const val TAG = "ReminderNotifHelper"
|
||||
const val CHANNEL_ID = "sp_reminders_channel"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Task and note reminders"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminder(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
) {
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
|
||||
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
val intent = Intent(context, ReminderAlarmReceiver::class.java).apply {
|
||||
action = ReminderAlarmReceiver.ACTION_SHOW_REMINDER
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_ID, reminderId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
|
||||
} else {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule reminder", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelReminder(context: Context, notificationId: Int) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, ReminderAlarmReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
fun showNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String
|
||||
) {
|
||||
createChannel(context)
|
||||
|
||||
// Tapping notification opens app
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
context, notificationId, contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// Snooze - handled by BroadcastReceiver, no app needed
|
||||
val snoozeIntent = Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
action = ReminderActionReceiver.ACTION_SNOOZE
|
||||
putExtra(ReminderActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_ID, reminderId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId * 10 + 1, snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(title)
|
||||
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.addAction(0, "Snooze 10m", snoozePendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No permission to show notification", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import com.superproductivity.superproductivity.App
|
|||
import com.superproductivity.superproductivity.BuildConfig
|
||||
import com.superproductivity.superproductivity.FullscreenActivity.Companion.WINDOW_INTERFACE_PROPERTY
|
||||
import com.superproductivity.superproductivity.app.LaunchDecider
|
||||
import com.superproductivity.superproductivity.service.FocusModeForegroundService
|
||||
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
|
||||
import com.superproductivity.superproductivity.service.TrackingForegroundService
|
||||
|
||||
|
||||
|
|
@ -108,6 +110,75 @@ class JavaScriptInterface(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun startFocusModeService(
|
||||
title: String,
|
||||
durationMs: Long,
|
||||
remainingMs: Long,
|
||||
isBreak: Boolean,
|
||||
isPaused: Boolean,
|
||||
taskTitle: String?
|
||||
) {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_START
|
||||
putExtra(FocusModeForegroundService.EXTRA_TITLE, title)
|
||||
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
|
||||
putExtra(FocusModeForegroundService.EXTRA_DURATION_MS, durationMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
|
||||
}
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun stopFocusModeService() {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_STOP
|
||||
}
|
||||
activity.startService(intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun updateFocusModeService(remainingMs: Long, isPaused: Boolean, taskTitle: String?) {
|
||||
val intent = Intent(activity, FocusModeForegroundService::class.java).apply {
|
||||
action = FocusModeForegroundService.ACTION_UPDATE
|
||||
putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs)
|
||||
putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused)
|
||||
putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle)
|
||||
}
|
||||
activity.startService(intent)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun scheduleNativeReminder(
|
||||
notificationId: Int,
|
||||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
) {
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
activity,
|
||||
notificationId,
|
||||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
triggerAtMs
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JavascriptInterface
|
||||
fun cancelNativeReminder(notificationId: Int) {
|
||||
ReminderNotificationHelper.cancelReminder(activity, notificationId)
|
||||
}
|
||||
|
||||
fun callJavaScriptFunction(script: String) {
|
||||
webView.post { webView.evaluateJavascript(script) { } }
|
||||
|
|
|
|||
|
|
@ -43,6 +43,33 @@ export interface AndroidInterface {
|
|||
stopTrackingService?(): void;
|
||||
getTrackingElapsed?(): string;
|
||||
|
||||
// Foreground service methods for focus mode timer
|
||||
startFocusModeService?(
|
||||
title: string,
|
||||
durationMs: number,
|
||||
remainingMs: number,
|
||||
isBreak: boolean,
|
||||
isPaused: boolean,
|
||||
taskTitle: string | null,
|
||||
): void;
|
||||
stopFocusModeService?(): void;
|
||||
updateFocusModeService?(
|
||||
remainingMs: number,
|
||||
isPaused: boolean,
|
||||
taskTitle: string | null,
|
||||
): void;
|
||||
|
||||
// Native reminder scheduling (snooze handled entirely in background)
|
||||
scheduleNativeReminder?(
|
||||
notificationId: number,
|
||||
reminderId: string,
|
||||
relatedId: string,
|
||||
title: string,
|
||||
reminderType: string,
|
||||
triggerAtMs: number,
|
||||
): void;
|
||||
cancelNativeReminder?(notificationId: number): void;
|
||||
|
||||
// added here only
|
||||
onResume$: Subject<void>;
|
||||
onPause$: Subject<void>;
|
||||
|
|
@ -58,6 +85,12 @@ export interface AndroidInterface {
|
|||
// Notification action callbacks
|
||||
onPauseTracking$: Subject<void>;
|
||||
onMarkTaskDone$: Subject<void>;
|
||||
|
||||
// Focus mode notification action callbacks
|
||||
onFocusPause$: Subject<void>;
|
||||
onFocusResume$: Subject<void>;
|
||||
onFocusSkip$: Subject<void>;
|
||||
onFocusComplete$: Subject<void>;
|
||||
}
|
||||
|
||||
// setInterval(() => {
|
||||
|
|
@ -75,6 +108,10 @@ if (IS_ANDROID_WEB_VIEW) {
|
|||
androidInterface.onPause$ = new Subject();
|
||||
androidInterface.onPauseTracking$ = new Subject();
|
||||
androidInterface.onMarkTaskDone$ = new Subject();
|
||||
androidInterface.onFocusPause$ = new Subject();
|
||||
androidInterface.onFocusResume$ = new Subject();
|
||||
androidInterface.onFocusSkip$ = new Subject();
|
||||
androidInterface.onFocusComplete$ = new Subject();
|
||||
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
|
||||
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
|
||||
|
||||
|
|
|
|||
195
src/app/features/android/store/android-focus-mode.effects.ts
Normal file
195
src/app/features/android/store/android-focus-mode.effects.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { map, pairwise, startWith, tap } from 'rxjs/operators';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { androidInterface } from '../android-interface';
|
||||
import {
|
||||
selectIsBreakActive,
|
||||
selectIsLongBreak,
|
||||
selectMode,
|
||||
selectTimeRemaining,
|
||||
selectTimer,
|
||||
} from '../../focus-mode/store/focus-mode.selectors';
|
||||
import * as focusModeActions from '../../focus-mode/store/focus-mode.actions';
|
||||
import { selectCurrentTask } from '../../tasks/store/task.selectors';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { FocusModeMode, TimerState } from '../../focus-mode/focus-mode.model';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
|
||||
@Injectable()
|
||||
export class AndroidFocusModeEffects {
|
||||
private _store = inject(Store);
|
||||
|
||||
// Start/stop focus mode notification when timer state changes
|
||||
syncFocusModeToNotification$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
combineLatest([
|
||||
this._store.select(selectTimer),
|
||||
this._store.select(selectMode),
|
||||
this._store.select(selectCurrentTask),
|
||||
this._store.select(selectIsBreakActive),
|
||||
this._store.select(selectIsLongBreak),
|
||||
this._store.select(selectTimeRemaining),
|
||||
]).pipe(
|
||||
map(
|
||||
([timer, mode, currentTask, isBreakActive, isLongBreak, timeRemaining]) => ({
|
||||
timer,
|
||||
mode,
|
||||
currentTask,
|
||||
isBreakActive,
|
||||
isLongBreak,
|
||||
timeRemaining,
|
||||
}),
|
||||
),
|
||||
startWith(null),
|
||||
pairwise(),
|
||||
tap(([prev, curr]) => {
|
||||
if (!curr) return;
|
||||
|
||||
const {
|
||||
timer,
|
||||
mode,
|
||||
currentTask,
|
||||
isBreakActive,
|
||||
isLongBreak,
|
||||
timeRemaining,
|
||||
} = curr;
|
||||
const taskTitle = currentTask?.title || null;
|
||||
|
||||
// Check if focus mode is active (has a purpose)
|
||||
const isFocusModeActive = timer.purpose !== null;
|
||||
const wasFocusModeActive = prev?.timer?.purpose !== null;
|
||||
|
||||
if (isFocusModeActive) {
|
||||
const title = this._getNotificationTitle(mode, isBreakActive, isLongBreak);
|
||||
const remainingMs = timer.duration > 0 ? timeRemaining : timer.elapsed; // Flowtime shows elapsed
|
||||
|
||||
// Start service if just became active, otherwise update
|
||||
if (!wasFocusModeActive) {
|
||||
DroidLog.log('AndroidFocusModeEffects: Starting focus mode service', {
|
||||
title,
|
||||
duration: timer.duration,
|
||||
remaining: remainingMs,
|
||||
isBreak: isBreakActive,
|
||||
isPaused: !timer.isRunning,
|
||||
});
|
||||
androidInterface.startFocusModeService?.(
|
||||
title,
|
||||
timer.duration,
|
||||
remainingMs,
|
||||
isBreakActive,
|
||||
!timer.isRunning,
|
||||
taskTitle,
|
||||
);
|
||||
} else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) {
|
||||
// Only update if something significant changed
|
||||
DroidLog.log('AndroidFocusModeEffects: Updating focus mode service', {
|
||||
remaining: remainingMs,
|
||||
isPaused: !timer.isRunning,
|
||||
});
|
||||
androidInterface.updateFocusModeService?.(
|
||||
remainingMs,
|
||||
!timer.isRunning,
|
||||
taskTitle,
|
||||
);
|
||||
}
|
||||
} else if (wasFocusModeActive && !isFocusModeActive) {
|
||||
// Focus mode ended, stop the service
|
||||
DroidLog.log('AndroidFocusModeEffects: Stopping focus mode service');
|
||||
androidInterface.stopFocusModeService?.();
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Handle notification action callbacks
|
||||
handleFocusPause$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusPause$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Pause action received')),
|
||||
map(() => focusModeActions.pauseFocusSession()),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusResume$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusResume$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Resume action received')),
|
||||
map(() => focusModeActions.unPauseFocusSession()),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusSkip$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusSkip$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Skip action received')),
|
||||
map(() => focusModeActions.skipBreak()),
|
||||
),
|
||||
);
|
||||
|
||||
handleFocusComplete$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(() =>
|
||||
androidInterface.onFocusComplete$.pipe(
|
||||
tap(() => DroidLog.log('AndroidFocusModeEffects: Complete action received')),
|
||||
map(() => focusModeActions.completeFocusSession({ isManual: true })),
|
||||
),
|
||||
);
|
||||
|
||||
private _getNotificationTitle(
|
||||
mode: FocusModeMode,
|
||||
isBreak: boolean,
|
||||
isLongBreak: boolean,
|
||||
): string {
|
||||
if (isBreak) {
|
||||
return isLongBreak ? 'Long Break' : 'Break';
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'Pomodoro':
|
||||
return 'Pomodoro';
|
||||
case 'Flowtime':
|
||||
return 'Flow';
|
||||
case 'Countdown':
|
||||
return 'Focus';
|
||||
default:
|
||||
return 'Focus';
|
||||
}
|
||||
}
|
||||
|
||||
private _hasStateChanged(
|
||||
prevTimer: TimerState | undefined,
|
||||
currTimer: TimerState,
|
||||
taskTitle: string | null,
|
||||
curr: {
|
||||
timer: TimerState;
|
||||
mode: FocusModeMode;
|
||||
currentTask: { title: string } | null;
|
||||
isBreakActive: boolean;
|
||||
isLongBreak: boolean;
|
||||
timeRemaining: number;
|
||||
},
|
||||
): boolean {
|
||||
if (!prevTimer) return true;
|
||||
|
||||
// Check if pause state changed
|
||||
if (prevTimer.isRunning !== currTimer.isRunning) return true;
|
||||
|
||||
// Check if purpose changed (work -> break or vice versa)
|
||||
if (prevTimer.purpose !== currTimer.purpose) return true;
|
||||
|
||||
// Only update notification every 5 seconds to reduce overhead
|
||||
// (native service already updates every second)
|
||||
const elapsedDiff = Math.abs(currTimer.elapsed - prevTimer.elapsed);
|
||||
if (elapsedDiff >= 5000) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
|
|||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
pairwise,
|
||||
startWith,
|
||||
tap,
|
||||
|
|
@ -16,6 +17,8 @@ import { selectCurrentTask } from '../../tasks/store/task.selectors';
|
|||
import { DroidLog } from '../../../core/log';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { selectTimer } from '../../focus-mode/store/focus-mode.selectors';
|
||||
import { combineLatest } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AndroidForegroundTrackingEffects {
|
||||
|
|
@ -26,21 +29,50 @@ export class AndroidForegroundTrackingEffects {
|
|||
/**
|
||||
* Start/stop the native foreground service when the current task changes.
|
||||
* Also handles syncing time when switching tasks directly.
|
||||
* NOTE: When focus mode is active, we hide the tracking notification
|
||||
* to avoid showing two notifications (focus mode notification takes priority).
|
||||
*/
|
||||
syncTrackingToService$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
() =>
|
||||
this._store.select(selectCurrentTask).pipe(
|
||||
distinctUntilChanged((a, b) => a?.id === b?.id),
|
||||
startWith(null as Task | null),
|
||||
combineLatest([
|
||||
this._store.select(selectCurrentTask),
|
||||
this._store.select(selectTimer),
|
||||
]).pipe(
|
||||
map(([currentTask, timer]) => ({
|
||||
currentTask,
|
||||
isFocusModeActive: timer.purpose !== null,
|
||||
})),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.currentTask?.id === b.currentTask?.id &&
|
||||
a.isFocusModeActive === b.isFocusModeActive,
|
||||
),
|
||||
startWith({ currentTask: null as Task | null, isFocusModeActive: false }),
|
||||
pairwise(),
|
||||
tap(([prevTask, currentTask]) => {
|
||||
tap(([prev, curr]) => {
|
||||
const { currentTask, isFocusModeActive } = curr;
|
||||
const prevTask = prev.currentTask;
|
||||
const wasFocusModeActive = prev.isFocusModeActive;
|
||||
|
||||
// If switching from one task to another (or stopping), sync the previous task's time first
|
||||
if (prevTask) {
|
||||
// Also sync when focus mode just started (to capture time tracked before focus mode)
|
||||
const focusModeJustStarted = isFocusModeActive && !wasFocusModeActive;
|
||||
if (prevTask && (!wasFocusModeActive || focusModeJustStarted)) {
|
||||
this._syncElapsedTimeForTask(prevTask.id);
|
||||
}
|
||||
|
||||
// Don't show tracking notification when focus mode is active
|
||||
// (focus mode notification takes priority)
|
||||
if (isFocusModeActive) {
|
||||
DroidLog.log(
|
||||
'Focus mode active, stopping tracking service to avoid duplicate notification',
|
||||
);
|
||||
androidInterface.stopTrackingService?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTask) {
|
||||
DroidLog.log('Starting tracking service', {
|
||||
taskId: currentTask.id,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { ReminderService } from '../../reminder/reminder.service';
|
|||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { LocalNotificationSchema } from '@capacitor/local-notifications/dist/esm/definitions';
|
||||
import { DroidLog } from '../../../core/log';
|
||||
import { generateNotificationId } from '../android-notification-id.util';
|
||||
import { androidInterface } from '../android-interface';
|
||||
|
|
@ -55,6 +54,8 @@ export class AndroidEffects {
|
|||
},
|
||||
);
|
||||
|
||||
// Use native reminder scheduling with BroadcastReceiver for actions
|
||||
// This allows snooze/done to work without opening the app
|
||||
scheduleNotifications$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
createEffect(
|
||||
|
|
@ -64,19 +65,17 @@ export class AndroidEffects {
|
|||
tap(async (reminders) => {
|
||||
try {
|
||||
if (!reminders || reminders.length === 0) {
|
||||
// Nothing to schedule yet, so avoid triggering the runtime permission dialog prematurely.
|
||||
return;
|
||||
}
|
||||
DroidLog.log('AndroidEffects: scheduling reminders', {
|
||||
DroidLog.log('AndroidEffects: scheduling reminders natively', {
|
||||
reminderCount: reminders.length,
|
||||
});
|
||||
|
||||
// Check permissions first
|
||||
const checkResult = await LocalNotifications.checkPermissions();
|
||||
DroidLog.log('AndroidEffects: pre-schedule permission check', checkResult);
|
||||
let displayPermissionGranted = checkResult.display === 'granted';
|
||||
if (!displayPermissionGranted) {
|
||||
// Reminder scheduling only works after the runtime permission is accepted.
|
||||
const requestResult = await LocalNotifications.requestPermissions();
|
||||
DroidLog.log({ requestResult });
|
||||
displayPermissionGranted = requestResult.display === 'granted';
|
||||
if (!displayPermissionGranted) {
|
||||
this._notifyPermissionIssue();
|
||||
|
|
@ -84,42 +83,25 @@ export class AndroidEffects {
|
|||
}
|
||||
}
|
||||
await this._ensureExactAlarmAccess();
|
||||
const pendingNotifications = await LocalNotifications.getPending();
|
||||
DroidLog.log({ pendingNotifications });
|
||||
if (pendingNotifications.notifications.length > 0) {
|
||||
await LocalNotifications.cancel({
|
||||
notifications: pendingNotifications.notifications.map((n) => ({
|
||||
id: n.id,
|
||||
})),
|
||||
});
|
||||
|
||||
// Schedule each reminder using native Android AlarmManager
|
||||
for (const reminder of reminders) {
|
||||
const id = generateNotificationId(reminder.relatedId);
|
||||
const now = Date.now();
|
||||
const scheduleAt =
|
||||
reminder.remindAt <= now ? now + 1000 : reminder.remindAt;
|
||||
|
||||
androidInterface.scheduleNativeReminder?.(
|
||||
id,
|
||||
reminder.id,
|
||||
reminder.relatedId,
|
||||
reminder.title,
|
||||
reminder.type,
|
||||
scheduleAt,
|
||||
);
|
||||
}
|
||||
// Re-schedule the full set so the native alarm manager is always in sync.
|
||||
await LocalNotifications.schedule({
|
||||
notifications: reminders.map((reminder) => {
|
||||
// Use deterministic ID based on reminder's relatedId to prevent duplicate notifications
|
||||
const id = generateNotificationId(reminder.relatedId);
|
||||
const now = Date.now();
|
||||
const scheduleAt =
|
||||
reminder.remindAt <= now ? now + 1000 : reminder.remindAt; // push overdue reminders into the immediate future
|
||||
const mapped: LocalNotificationSchema = {
|
||||
id,
|
||||
title: reminder.title,
|
||||
body: '',
|
||||
extra: {
|
||||
reminder,
|
||||
},
|
||||
schedule: {
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
at: new Date(scheduleAt),
|
||||
allowWhileIdle: true,
|
||||
repeats: false,
|
||||
every: undefined,
|
||||
},
|
||||
};
|
||||
return mapped;
|
||||
}),
|
||||
});
|
||||
DroidLog.log('AndroidEffects: scheduled local notifications', {
|
||||
|
||||
DroidLog.log('AndroidEffects: scheduled native reminders', {
|
||||
reminderCount: reminders.length,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||
import { ReminderService } from './reminder.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import {
|
||||
concatMap,
|
||||
delay,
|
||||
|
|
@ -121,6 +122,11 @@ export class ReminderModule {
|
|||
|
||||
@throttle(60000)
|
||||
private _showNotification(reminders: Reminder[]): void {
|
||||
// Skip on Android - we use native notifications with snooze button instead
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMultiple = reminders.length > 1;
|
||||
const title = isMultiple
|
||||
? '"' +
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { workContextReducer } from '../features/work-context/store/work-context.
|
|||
import { WorkContextEffects } from '../features/work-context/store/work-context.effects';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../util/is-android-web-view';
|
||||
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 { CaldavIssueEffects } from '../features/issue/providers/caldav/caldav-issue.effects';
|
||||
import { CalendarIntegrationEffects } from '../features/calendar-integration/store/calendar-integration.effects';
|
||||
|
|
@ -153,7 +154,9 @@ import { PluginHooksEffects } from '../plugins/plugin-hooks.effects';
|
|||
|
||||
// EFFECTS ONLY
|
||||
EffectsModule.forFeature([
|
||||
...(IS_ANDROID_WEB_VIEW ? [AndroidEffects, AndroidForegroundTrackingEffects] : []),
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [AndroidEffects, AndroidFocusModeEffects, AndroidForegroundTrackingEffects]
|
||||
: []),
|
||||
]),
|
||||
EffectsModule.forFeature([CaldavIssueEffects]),
|
||||
EffectsModule.forFeature([CalendarIntegrationEffects]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue