diff --git a/Android/src/app/src/main/AndroidManifest.xml b/Android/src/app/src/main/AndroidManifest.xml
index 15e0de78d..cec1b2edf 100644
--- a/Android/src/app/src/main/AndroidManifest.xml
+++ b/Android/src/app/src/main/AndroidManifest.xml
@@ -30,6 +30,10 @@
+
+
+
+
@@ -140,6 +144,18 @@
+
+
+
+
+
+
+
+
diff --git a/Android/src/app/src/main/assets/skills/schedule-notification/SKILL.md b/Android/src/app/src/main/assets/skills/schedule-notification/SKILL.md
new file mode 100644
index 000000000..d29bd3b01
--- /dev/null
+++ b/Android/src/app/src/main/assets/skills/schedule-notification/SKILL.md
@@ -0,0 +1,26 @@
+---
+name: schedule-notification
+description: Schedule a notification for a specific date or repeating daily.
+---
+
+# Schedule Notification
+
+## Instructions
+
+To schedule a notification, you must follow these exact steps:
+1. First, if the notification doesn't need to repeat daily, call the `run_intent` tool with `intent` as `get_current_date_and_time` and `parameters` as `{}` to get the user's local date and time. Then explicitly calculate the scheduling date and time in your response. Write out:
+- Today's exact date.
+- The target day or relative time requested by the user (e.g., "tomorrow", "this Friday").
+- The exact number of days you need to add to today's date.
+- The final calculated dates, ensuring you correctly roll over to the next month or year if the added days exceed the days in the current month.
+2. Call the `run_intent` tool with the following exact parameters:
+- intent: schedule_notification
+- parameters: A JSON string with the following fields:
+ - title: the title of the notification. String.
+ - message: the message content of the notification. String.
+ - hour: the hour of the day (0-23) for the notification. Integer.
+ - minute: the minute of the hour (0-59) for the notification. Integer.
+ - year: (optional) the year for the notification. Integer.
+ - month: (optional) the month (1-12) for the notification. Integer.
+ - day: (optional) the day of the month (1-31) for the notification. Integer.
+ - repeat_daily: (optional) true if the notification should repeat daily at this time. Boolean. - deeplink: (optional) the deeplink URI to open when the notification is tapped. String.
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt
index 960b76393..1ca3752ef 100644
--- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/GalleryApplication.kt
@@ -18,6 +18,7 @@ package com.google.ai.edge.gallery
import android.app.Application
import com.google.ai.edge.gallery.data.DataStoreRepository
+import com.google.ai.edge.gallery.notifications.NotificationScheduleManager
import com.google.ai.edge.gallery.ui.theme.ThemeSettings
import com.google.firebase.FirebaseApp
import dagger.hilt.android.HiltAndroidApp
@@ -27,9 +28,13 @@ import javax.inject.Inject
class GalleryApplication : Application() {
@Inject lateinit var dataStoreRepository: DataStoreRepository
+ @Inject lateinit var notificationScheduleManager: NotificationScheduleManager
override fun onCreate() {
super.onCreate()
+ // Initialize the notification schedule manager to load the scheduled notifications from the
+ // disk.
+ notificationScheduleManager.initialize()
// Load saved theme.
ThemeSettings.themeOverride.value = dataStoreRepository.readTheme()
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/IntentHandler.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/IntentHandler.kt
index ae530ea0e..a2678bc19 100644
--- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/IntentHandler.kt
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/IntentHandler.kt
@@ -19,8 +19,11 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.net.toUri
+import com.google.ai.edge.gallery.notifications.NotificationScheduleManagerEntryPoint
+import com.google.ai.edge.gallery.proto.ScheduledNotification
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
+import dagger.hilt.android.EntryPointAccessors
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.Date
@@ -48,13 +51,27 @@ enum class IntentAction(val action: String) {
SEND_EMAIL("send_email"),
SEND_SMS("send_sms"),
CREATE_CALENDAR_EVENT("create_calendar_event"),
- GET_CURRENT_DATE_AND_TIME("get_current_date_and_time");
+ GET_CURRENT_DATE_AND_TIME("get_current_date_and_time"),
+ SCHEDULE_NOTIFICATION("schedule_notification");
companion object {
fun from(action: String): IntentAction? = entries.find { it.action == action }
}
}
+@JsonClass(generateAdapter = true)
+data class ScheduleNotificationParams(
+ val title: String,
+ val message: String,
+ val hour: Int,
+ val minute: Int,
+ val year: Int? = null,
+ val month: Int? = null,
+ val day: Int? = null,
+ val repeat_daily: Boolean? = null,
+ val deeplink: String? = null,
+)
+
object IntentHandler {
private const val TAG = "IntentHandler"
@@ -142,7 +159,64 @@ object IntentHandler {
)
currentDateAndTime
}
+ IntentAction.SCHEDULE_NOTIFICATION -> {
+ scheduleNotification(context, parameters)
+ }
null -> "failed"
}
}
+
+ fun scheduleNotification(context: Context, parameters: String): String {
+ try {
+ val moshi = Moshi.Builder().build()
+ val jsonAdapter = moshi.adapter(ScheduleNotificationParams::class.java)
+ val params = jsonAdapter.fromJson(parameters)
+ if (params != null) {
+ val notificationProtoBuilder =
+ ScheduledNotification.newBuilder()
+ .setId(java.util.UUID.randomUUID().toString())
+ .setTitle(params.title)
+ .setMessage(params.message)
+ .setHour(params.hour)
+ .setMinute(params.minute)
+ .setChannelId("agent_skill_tasks_channel")
+ .setChannelName("Agent Skill Task")
+ if (params.year != null) {
+ notificationProtoBuilder.setYear(params.year)
+ }
+ if (params.month != null) {
+ notificationProtoBuilder.setMonth(params.month)
+ }
+ if (params.day != null) {
+ notificationProtoBuilder.setDay(params.day)
+ }
+ if (params.deeplink != null) {
+ notificationProtoBuilder.setDeeplink(params.deeplink)
+ }
+ if (params.repeat_daily != null) {
+ notificationProtoBuilder.setRepeatDaily(params.repeat_daily)
+ }
+
+ val entryPoint =
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ NotificationScheduleManagerEntryPoint::class.java,
+ )
+ val success =
+ entryPoint
+ .notificationScheduleManager()
+ .scheduleNotification(notificationProtoBuilder.build())
+ if (!success) {
+ return "failed"
+ }
+ return "succeeded"
+ } else {
+ Log.e(TAG, "Failed to parse schedule_notification parameters: $parameters")
+ return "failed"
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse schedule_notification parameters: $parameters", e)
+ return "failed"
+ }
+ }
}
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/SkillManagerViewModel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/SkillManagerViewModel.kt
index d4015d72c..6faf3d29e 100644
--- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/SkillManagerViewModel.kt
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/customtasks/agentchat/SkillManagerViewModel.kt
@@ -192,8 +192,8 @@ constructor(
try {
val skillAssetDirs = context.assets.list("skills") ?: emptyArray()
for (dirName in skillAssetDirs) {
- // Temporarily disable this skill in built-in skills.
- if (dirName == "create-calendar-event") {
+ // TODO: Temporarily disable some built-in skills. Enable them when ready.
+ if (dirName == "create-calendar-event" || dirName == "schedule-notification") {
continue
}
val skillMdPath = "skills/$dirName/SKILL.md"
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/BootReceiver.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/BootReceiver.kt
new file mode 100644
index 000000000..c25464846
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/BootReceiver.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.ai.edge.gallery.notifications
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import dagger.hilt.android.EntryPointAccessors
+
+/**
+ * Reschedules all notifications after the device boots up.
+ *
+ * This receiver is triggered by the ACTION_BOOT_COMPLETED broadcast.
+ */
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
+ Log.d(TAG, "Boot completed received, rescheduling notifications")
+ try {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ NotificationScheduleManagerEntryPoint::class.java,
+ )
+ entryPoint.notificationScheduleManager().rescheduleAllNotifications()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to reschedule notifications on boot", e)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "BootReceiver"
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationPendingIntentHelper.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationPendingIntentHelper.kt
new file mode 100644
index 000000000..2bba5e8e8
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationPendingIntentHelper.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.ai.edge.gallery.notifications
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+
+// Helper class for building a PendingIntent for a notification.
+object NotificationPendingIntentHelper {
+ const val EXTRA_ID = "id"
+ const val EXTRA_TITLE = "title"
+ const val EXTRA_MESSAGE = "message"
+ const val EXTRA_DEEPLINK = "deeplink"
+ const val EXTRA_REPEAT_DAILY = "repeat_daily"
+ const val EXTRA_HOUR = "hour"
+ const val EXTRA_MINUTE = "minute"
+ const val EXTRA_CHANNEL_ID = "channel_id"
+ const val EXTRA_CHANNEL_NAME = "channel_name"
+
+ fun buildNotificationPendingIntent(
+ context: Context,
+ id: String,
+ title: String,
+ message: String,
+ deeplink: String,
+ repeatDaily: Boolean,
+ hour: Int,
+ minute: Int,
+ channelId: String,
+ channelName: String,
+ ): PendingIntent {
+ val receiverClass =
+ Class.forName("com.google.ai.edge.gallery.notifications.NotificationReceiver")
+ val intent =
+ Intent(context, receiverClass).apply {
+ putExtra(EXTRA_ID, id)
+ putExtra(EXTRA_TITLE, title)
+ putExtra(EXTRA_MESSAGE, message)
+ putExtra(EXTRA_DEEPLINK, deeplink)
+ putExtra(EXTRA_REPEAT_DAILY, repeatDaily)
+ putExtra(EXTRA_HOUR, hour)
+ putExtra(EXTRA_MINUTE, minute)
+ putExtra(EXTRA_CHANNEL_ID, channelId)
+ putExtra(EXTRA_CHANNEL_NAME, channelName)
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ id.hashCode(),
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationReceiver.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationReceiver.kt
new file mode 100644
index 000000000..1c38ee2ee
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationReceiver.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.ai.edge.gallery.notifications
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
+import dagger.hilt.android.EntryPointAccessors
+
+class NotificationReceiver : BroadcastReceiver() {
+ private val DEFAULT_CHANNEL_ID = "ai_edge_gallery_notification_channel"
+ private val DEFAULT_CHANNEL_NAME = "AI Edge Gallery Notifications"
+
+ override fun onReceive(context: Context, intent: Intent) {
+ Log.d(TAG, "onReceive called with intent: $intent")
+ val id = intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_ID) ?: ""
+ val title =
+ intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_TITLE) ?: "Scheduled task"
+ val message =
+ intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_MESSAGE)
+ ?: "Time to complete your task!"
+ val deeplink = intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_DEEPLINK) ?: ""
+ val channelId =
+ intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_CHANNEL_ID) ?: DEFAULT_CHANNEL_ID
+ val channelName =
+ intent.getStringExtra(NotificationPendingIntentHelper.EXTRA_CHANNEL_NAME)
+ ?: DEFAULT_CHANNEL_NAME
+
+ try {
+ // Create the intent for when tapping the notification
+ val contentIntent =
+ Intent(Intent.ACTION_VIEW).apply {
+ if (deeplink.isNotEmpty()) {
+ data = deeplink.toUri()
+ }
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+
+ val pendingIntent =
+ PendingIntent.getActivity(
+ context,
+ 0,
+ contentIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ val notificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel =
+ NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val notificationBuilder =
+ NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+
+ notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
+ // If the notification is not repeating, remove it from the schedule after it is sent.
+ if (
+ !intent.getBooleanExtra(NotificationPendingIntentHelper.EXTRA_REPEAT_DAILY, false) &&
+ id.isNotEmpty()
+ ) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ NotificationScheduleManagerEntryPoint::class.java,
+ )
+ entryPoint.notificationScheduleManager().removeNotification(id)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send notification", e)
+ }
+ }
+
+ companion object {
+ private const val TAG = "NotificationReceiver"
+ }
+}
diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationScheduleManager.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationScheduleManager.kt
new file mode 100644
index 000000000..76d60eff7
--- /dev/null
+++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/notifications/NotificationScheduleManager.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.ai.edge.gallery.notifications
+
+import android.app.AlarmManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.DataStoreFactory
+import androidx.datastore.core.Serializer
+import com.google.ai.edge.gallery.proto.ScheduledNotification
+import com.google.ai.edge.gallery.proto.ScheduledNotifications
+import com.google.protobuf.InvalidProtocolBufferException
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.Calendar
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+// Manages the scheduled notifications in the agent chat skill, including loading and saving
+// notifications to the disk, and canceling/updating scheduled notifications when they are removed.
+// This is a global singleton object, and is thread-safe.
+@Singleton
+class NotificationScheduleManager
+@Inject
+constructor(@ApplicationContext private val context: Context) {
+ private val dataStore: DataStore =
+ DataStoreFactory.create(
+ serializer = ScheduledNotificationsSerializer,
+ produceFile = { File(context.filesDir, "scheduled_notifications.pb") },
+ )
+ private val TAG = "NotificationScheduleManager"
+
+ // Use a coroutine scope for IO operations to avoid blocking the calling thread.
+ private val coroutineScope = CoroutineScope(Dispatchers.IO)
+
+ private val _scheduledNotifications = MutableStateFlow>(emptyList())
+ val scheduledNotifications = _scheduledNotifications.asStateFlow()
+
+ init {
+ loadNotifications()
+ }
+
+ fun initialize() = Unit
+
+ /** Loads the scheduled notifications from the disk. */
+ private fun loadNotifications() {
+ coroutineScope.launch {
+ try {
+ val file = File(context.filesDir, "scheduled_notifications.pb")
+ if (file.exists()) {
+ val data = file.inputStream().use { ScheduledNotificationsSerializer.readFrom(it) }
+ _scheduledNotifications.value = data.notificationList
+ }
+ } catch (e: Exception) {
+ // Ignore on read fault
+ }
+ }
+ }
+
+ /** Saves the scheduled notifications to the disk. */
+ private fun saveNotifications() {
+ coroutineScope.launch {
+ dataStore.updateData {
+ ScheduledNotifications.newBuilder()
+ .addAllNotification(_scheduledNotifications.value)
+ .build()
+ }
+ }
+ }
+
+ /**
+ * Schedules a notification and returns true if the notification was scheduled successfully,
+ * otherwise returns false.
+ */
+ fun scheduleNotification(notification: ScheduledNotification): Boolean {
+ if (!setAlarmForNotification(notification)) {
+ return false
+ }
+ _scheduledNotifications.update { it + notification }
+ saveNotifications()
+ return true
+ }
+
+ /**
+ * Sets an alarm for a notification. If the notification has a date, the alarm is set for the
+ * specified date and time. Otherwise, the alarm is set for the specified time on the current day.
+ * If the specified time is in the past, the alarm is set for the specified time on the next day.
+ * If repeatDaily is true, the alarm is set to repeat daily.
+ *
+ * @param notification The notification to schedule.
+ * @return True if the notification was scheduled successfully, otherwise false.
+ */
+ private fun setAlarmForNotification(notification: ScheduledNotification): Boolean {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ // On Android S (API 31) and later, we must check whether we have permission to set exact
+ // alarms. If we don't have permission, don't schedule alarms for notifications.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (!alarmManager.canScheduleExactAlarms()) {
+ // TODO: Request permission from user.
+ Log.e(TAG, "Failed to schedule notification: permission not granted")
+ return false
+ }
+ }
+
+ val pendingIntent =
+ NotificationPendingIntentHelper.buildNotificationPendingIntent(
+ context,
+ notification.id,
+ notification.title,
+ notification.message,
+ notification.deeplink,
+ notification.repeatDaily,
+ notification.hour,
+ notification.minute,
+ notification.channelId,
+ notification.channelName,
+ )
+
+ val calendar =
+ Calendar.getInstance().apply {
+ timeInMillis = System.currentTimeMillis()
+ if (notification.hasYear() && notification.hasMonth() && notification.hasDay()) {
+ set(Calendar.YEAR, notification.year)
+ set(Calendar.MONTH, notification.month - 1)
+ set(Calendar.DAY_OF_MONTH, notification.day)
+ }
+ set(Calendar.HOUR_OF_DAY, notification.hour)
+ set(Calendar.MINUTE, notification.minute)
+ set(Calendar.SECOND, 0)
+ if (before(Calendar.getInstance())) {
+ if (
+ notification.repeatDaily ||
+ (!notification.hasYear() && !notification.hasMonth() && !notification.hasDay())
+ ) {
+ add(Calendar.DATE, 1)
+ }
+ }
+ }
+
+ if (notification.repeatDaily) {
+ alarmManager.setRepeating(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ AlarmManager.INTERVAL_DAY,
+ pendingIntent,
+ )
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ calendar.timeInMillis,
+ pendingIntent,
+ )
+ } else {
+ alarmManager.setExact(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
+ }
+ return true
+ }
+
+ /** Reschedules all loaded notifications. Typically called after device boot. */
+ fun rescheduleAllNotifications() {
+ coroutineScope.launch {
+ try {
+ val file = File(context.filesDir, "scheduled_notifications.pb")
+ if (file.exists()) {
+ val data = file.inputStream().use { ScheduledNotificationsSerializer.readFrom(it) }
+ for (notification in data.notificationList) {
+ val unused = setAlarmForNotification(notification)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to reschedule notifications", e)
+ }
+ }
+ }
+
+ /** Removes a notification from the schedule and cancels the alarm for the notification. */
+ fun removeNotification(id: String) {
+ val removed = _scheduledNotifications.value.find { it.id == id }
+ removed?.let {
+ val pendingIntent =
+ NotificationPendingIntentHelper.buildNotificationPendingIntent(
+ context,
+ it.id,
+ it.title,
+ it.message,
+ it.deeplink,
+ it.repeatDaily,
+ it.hour,
+ it.minute,
+ it.channelId,
+ it.channelName,
+ )
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.cancel(pendingIntent)
+ }
+ _scheduledNotifications.update { list -> list.filter { it.id != id } }
+ saveNotifications()
+ }
+}
+
+object ScheduledNotificationsSerializer : Serializer {
+ override val defaultValue: ScheduledNotifications = ScheduledNotifications.getDefaultInstance()
+
+ override suspend fun readFrom(input: InputStream): ScheduledNotifications {
+ try {
+ return ScheduledNotifications.parseFrom(input)
+ } catch (exception: InvalidProtocolBufferException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+ }
+
+ override suspend fun writeTo(t: ScheduledNotifications, output: OutputStream) = t.writeTo(output)
+}
+
+@dagger.hilt.EntryPoint
+@dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class)
+interface NotificationScheduleManagerEntryPoint {
+ fun notificationScheduleManager(): NotificationScheduleManager
+}
diff --git a/Android/src/app/src/main/proto/scheduled_notification.proto b/Android/src/app/src/main/proto/scheduled_notification.proto
new file mode 100644
index 000000000..e155fa96c
--- /dev/null
+++ b/Android/src/app/src/main/proto/scheduled_notification.proto
@@ -0,0 +1,53 @@
+/* Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+syntax = "proto3";
+
+package com.google.ai.edge.gallery.proto;
+
+option java_package = "com.google.ai.edge.gallery.proto";
+option java_outer_classname = "ScheduledNotificationOuterClass";
+option java_multiple_files = true;
+
+// This is a single notification that is scheduled to be sent at a specific time
+// and date, and may repeat daily.
+// Next ID: 13
+message ScheduledNotification {
+ // The unique identifier of the notification.
+ string id = 1;
+ // The title of the notification.
+ string title = 2;
+ // The message of the notification.
+ string message = 3;
+ // The channel ID of the notification.
+ string channel_id = 4;
+ // The channel name of the notification.
+ string channel_name = 5;
+ int32 hour = 6;
+ int32 minute = 7;
+ optional int32 year = 8;
+ optional int32 month = 9;
+ optional int32 day = 10;
+ // Whether the notification should repeat daily.
+ optional bool repeat_daily = 11;
+ // The deeplink to open when the notification is tapped.
+ optional string deeplink = 12;
+}
+
+// This is a list of all scheduled notifications.
+// Next ID: 2
+message ScheduledNotifications {
+ repeated ScheduledNotification notification = 1;
+}