From b92644eb11e68a0b25f58e669779611201a78452 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 24 Jun 2026 19:39:05 +0000 Subject: [PATCH] feat: expose native reminder creation --- .../studyOS/Reminder/ReminderManager.java | 9 +- .../studyOS/Reminder/ReminderReceiver.java | 95 +++++++++++++++++- .../AndroidNativeToolExecutor.kt | 98 ++++++++++++++++++- .../com/studyos/studyos_agent/MainActivity.kt | 3 +- .../ios/Runner/StudyOSNativeBridge.swift | 60 +++++++++++- flutter_app/lib/src/native_tool_router.dart | 1 + flutter_app/lib/src/studyos_tool_catalog.dart | 30 ++++++ flutter_app/test/cloud_agent_client_test.dart | 6 +- flutter_app/test/native_tool_router_test.dart | 24 ++++- .../test/studyos_tool_executor_test.dart | 19 +++- 10 files changed, 325 insertions(+), 20 deletions(-) diff --git a/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderManager.java b/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderManager.java index 490fb4b..db853a6 100644 --- a/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderManager.java +++ b/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderManager.java @@ -52,6 +52,9 @@ public void init(Context ctx) { EINZIGE öffentliche Methode */ public String create(String title, LocalDateTime time, Type type, Repeat repeat) { + if (context == null) + throw new IllegalStateException("ReminderManager is not initialized."); + String id = UUID.randomUUID().toString().substring(0, 8); long trigger = time .atZone(ZoneId.systemDefault()) @@ -74,7 +77,9 @@ public String create(String title, LocalDateTime time, Type type, Repeat repeat) save(arr); schedule(obj); - } catch (Exception ignored) {} + } catch (Exception error) { + throw new RuntimeException("Reminder could not be scheduled: " + error.getMessage(), error); + } return id; } @@ -197,4 +202,4 @@ private void save(JSONArray arr) { fos.write(arr.toString().getBytes()); } catch (Exception ignored) {} } -} \ No newline at end of file +} diff --git a/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderReceiver.java b/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderReceiver.java index 22d52e4..5a35972 100644 --- a/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderReceiver.java +++ b/flutter_app/android/app/src/main/java/com/example/studyOS/Reminder/ReminderReceiver.java @@ -1,11 +1,23 @@ package com.example.studyOS.Reminder; +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 androidx.core.app.NotificationCompat; + +import com.studyos.studyos_agent.MainActivity; +import com.studyos.studyos_agent.R; + +import org.json.JSONObject; public class ReminderReceiver extends BroadcastReceiver { + private static final String CHANNEL_ID = "studyos_reminders"; private static long lastTrigger = 0; @Override @@ -25,9 +37,84 @@ public void onReceive(Context context, Intent intent) { return; } - var service = new Intent(context, ReminderService.class); - service.putExtras(intent); + showReminderNotification(context, intent); + advanceReminderSchedule(context, intent); + } + + private void showReminderNotification(Context context, Intent intent) { + createChannel(context); + + var title = intent.getStringExtra(ReminderManager.EXTRA_TITLE); + if (title == null || title.trim().isEmpty()) + title = "StudyOS reminder"; + + var type = intent.getStringExtra(ReminderManager.EXTRA_TYPE); + var notificationTitle = "StudyOS reminder"; + if ("ALARM".equals(type)) + notificationTitle = "StudyOS alarm"; + if ("MORNING_ROUTINE".equals(type)) + notificationTitle = "StudyOS morning routine"; + + var launchIntent = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (launchIntent == null) + launchIntent = new Intent(context, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + var contentIntent = PendingIntent.getActivity( + context, + notificationId(intent), + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + var notification = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(notificationTitle) + .setContentText(title) + .setStyle(new NotificationCompat.BigTextStyle().bigText(title)) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .build(); + + var manager = context.getSystemService(NotificationManager.class); + if (manager != null) + manager.notify(notificationId(intent), notification); + } + + private void advanceReminderSchedule(Context context, Intent intent) { + try { + var obj = new JSONObject() + .put("id", intent.getStringExtra(ReminderManager.EXTRA_ID)) + .put("title", intent.getStringExtra(ReminderManager.EXTRA_TITLE)) + .put("type", intent.getStringExtra(ReminderManager.EXTRA_TYPE)) + .put("repeat", intent.getStringExtra(ReminderManager.EXTRA_REPEAT)) + .put("time", intent.getLongExtra(ReminderManager.EXTRA_TIME, 0)); + + ReminderManager.get().init(context); + ReminderManager.get().next(obj); + } catch (Exception ignored) {} + } + + private void createChannel(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + return; + + var channel = new NotificationChannel( + CHANNEL_ID, + "StudyOS reminders", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Local StudyOS reminder notifications."); + var manager = context.getSystemService(NotificationManager.class); + if (manager != null) + manager.createNotificationChannel(channel); + } - context.startForegroundService(service); + private int notificationId(Intent intent) { + var id = intent.getStringExtra(ReminderManager.EXTRA_ID); + return id == null ? 31_031 : id.hashCode(); } -} \ No newline at end of file +} diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt index eb826ef..cf7513a 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/AndroidNativeToolExecutor.kt @@ -1,10 +1,20 @@ package com.studyos.studyos_agent +import android.Manifest +import android.app.AlarmManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager +import android.os.Build import android.provider.Settings +import com.example.studyOS.Reminder.ReminderManager import com.example.studyOS.offline.Tools +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.util.Locale class AndroidNativeToolExecutor(context: Context) { private val appContext = context.applicationContext @@ -25,10 +35,10 @@ class AndroidNativeToolExecutor(context: Context) { supported("open_installed_app"), supported("search_youtube"), supported("open_system_setting"), - mapOf( - "name" to "create_reminder", - "supported" to false, - "reason" to "Reminder scheduling is reserved for the reminder PR.", + supported( + "create_reminder", + canCreateReminder(), + reminderUnsupportedReason(), ), ) } @@ -47,10 +57,15 @@ class AndroidNativeToolExecutor(context: Context) { "Opened YouTube search for '$query'." } "open_system_setting" -> openSystemSetting(stringArgument(arguments, "setting")) + "create_reminder" -> createReminder(arguments) else -> throw IllegalArgumentException("Native tool is not available: $name") } } + fun canCreateReminder(): Boolean { + return hasNotificationPermission() + } + private fun openSystemSetting(setting: String): String { val normalized = setting.trim().lowercase() val action = when (normalized) { @@ -67,6 +82,42 @@ class AndroidNativeToolExecutor(context: Context) { return "Opened $normalized settings. Direct toggles are controlled by Android." } + private fun createReminder(arguments: Map<*, *>): String { + val title = stringArgument(arguments, "title") + val time = localDateTimeArgument(arguments, "time") + val type = enumArgument( + arguments, + "type", + ReminderManager.Type.REMINDER, + ) + val repeat = enumArgument( + arguments, + "repeat", + ReminderManager.Repeat.ONCE, + ) + ReminderManager.get().init(appContext) + val id = ReminderManager.get().create(title, time, type, repeat) + return "Created ${type.name.lowercase(Locale.US)} '$title' reminder ($repeat) with id $id." + } + + private fun reminderUnsupportedReason(): String? { + if (!hasNotificationPermission()) { + return "Notification permission is required before reminders can be created." + } + return null + } + + private fun hasNotificationPermission(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + appContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PERMISSION_GRANTED + } + + fun canScheduleExactAlarms(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + return alarmManager?.canScheduleExactAlarms() == true + } + private fun supported( name: String, supported: Boolean = true, @@ -94,4 +145,43 @@ class AndroidNativeToolExecutor(context: Context) { else -> throw IllegalArgumentException("Missing required boolean '$key' argument.") } } + + private fun localDateTimeArgument(arguments: Map<*, *>, key: String): LocalDateTime { + val value = stringArgument(arguments, key) + return runCatching { + OffsetDateTime.parse(value) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime() + }.getOrElse { + runCatching { + Instant.parse(value) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + }.getOrElse { + runCatching { + LocalDateTime.parse(value) + }.getOrElse { + throw IllegalArgumentException( + "Reminder '$key' must be an ISO-8601 timestamp.", + ) + } + } + } + } + + private inline fun > enumArgument( + arguments: Map<*, *>, + key: String, + defaultValue: T, + ): T { + val value = arguments[key]?.toString()?.trim() + if (value.isNullOrEmpty()) return defaultValue + return runCatching { + enumValueOf(value.uppercase(Locale.US)) + }.getOrElse { + throw IllegalArgumentException( + "Unsupported '$key' value '$value'.", + ) + } + } } diff --git a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt index 1de22d0..0caf48a 100644 --- a/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt +++ b/flutter_app/android/app/src/main/kotlin/com/studyos/studyos_agent/MainActivity.kt @@ -417,7 +417,8 @@ class MainActivity : FlutterActivity() { "canUseBackgroundLocation" to hasPermission( Manifest.permission.ACCESS_BACKGROUND_LOCATION, ), - "canCreateExactAlarm" to true, + "canCreateExactAlarm" to nativeToolExecutor().canScheduleExactAlarms(), + "canCreateLocalReminder" to nativeToolExecutor().canCreateReminder(), "canOpenInstalledApps" to true, "canReadCalendar" to hasPermission(Manifest.permission.READ_CALENDAR), "canUseOfflineLiteRtModel" to true, diff --git a/flutter_app/ios/Runner/StudyOSNativeBridge.swift b/flutter_app/ios/Runner/StudyOSNativeBridge.swift index 56a75c6..d6e2274 100644 --- a/flutter_app/ios/Runner/StudyOSNativeBridge.swift +++ b/flutter_app/ios/Runner/StudyOSNativeBridge.swift @@ -172,6 +172,10 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag return } + scheduleLocalReminder(title: title, secondsFromNow: seconds, result: result) + } + + private func scheduleLocalReminder(title: String, secondsFromNow: Double, result: @escaping FlutterResult) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in if let error = error { result(FlutterError(code: "notification_permission_error", message: error.localizedDescription, details: nil)) @@ -185,7 +189,7 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag let content = UNMutableNotificationContent() content.title = title content.sound = .default - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: max(seconds, 1), repeats: false) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: max(secondsFromNow, 1), repeats: false) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if let error = error { @@ -213,7 +217,7 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag result("iOS speech started.") } - private func executeNativeTool(call: FlutterMethodCall, result: FlutterResult) { + private func executeNativeTool(call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any], let name = args["name"] as? String @@ -222,6 +226,15 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag return } + if name == "create_reminder" { + guard let toolArgs = args["arguments"] as? [String: Any] else { + result(FlutterError(code: "invalid_reminder", message: "Expected reminder arguments.", details: nil)) + return + } + createReminderFromNativeTool(arguments: toolArgs, result: result) + return + } + result(FlutterError( code: "native_tool_unsupported", message: "Native tool is not supported on iOS in this build: \(name).", @@ -229,6 +242,47 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag )) } + private func createReminderFromNativeTool(arguments: [String: Any], result: @escaping FlutterResult) { + guard + let title = arguments["title"] as? String, + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let time = arguments["time"] as? String, + let date = parseIsoDate(time) + else { + result(FlutterError(code: "invalid_reminder", message: "Expected title and ISO-8601 time.", details: nil)) + return + } + + let type = (arguments["type"] as? String ?? "REMINDER").uppercased() + guard type == "REMINDER" else { + result(FlutterError(code: "unsupported_reminder_type", message: "iOS supports one-time reminder notifications only.", details: nil)) + return + } + + let repeatValue = (arguments["repeat"] as? String ?? "ONCE").uppercased() + guard repeatValue == "ONCE" else { + result(FlutterError(code: "unsupported_reminder_repeat", message: "iOS reminder repeat is not supported in this build.", details: nil)) + return + } + + let seconds = date.timeIntervalSinceNow + guard seconds > 0 else { + result(FlutterError(code: "invalid_reminder_time", message: "Reminder time must be in the future.", details: nil)) + return + } + + scheduleLocalReminder(title: title, secondsFromNow: seconds, result: result) + } + + private func parseIsoDate(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { + return date + } + return ISO8601DateFormatter().date(from: value) + } + private func worldState() -> [String: Any] { var state: [String: Any] = [ "platform": "ios", @@ -294,7 +348,7 @@ final class StudyOSNativeBridge: NSObject, FlutterStreamHandler, CLLocationManag ["name": "open_installed_app", "supported": false, "reason": "iOS does not support arbitrary installed-app launching from this app."], ["name": "search_youtube", "supported": false, "reason": iosControlReason], ["name": "open_system_setting", "supported": false, "reason": iosControlReason], - ["name": "create_reminder", "supported": false, "reason": "Reminder tool exposure is reserved for the reminder PR."] + ["name": "create_reminder", "supported": true] ] } diff --git a/flutter_app/lib/src/native_tool_router.dart b/flutter_app/lib/src/native_tool_router.dart index b5f0c4e..ec17601 100644 --- a/flutter_app/lib/src/native_tool_router.dart +++ b/flutter_app/lib/src/native_tool_router.dart @@ -17,6 +17,7 @@ const activeNativeToolNames = { nativeOpenInstalledAppToolName, nativeSearchYoutubeToolName, nativeOpenSystemSettingToolName, + nativeCreateReminderToolName, }; abstract class NativeToolRunner { diff --git a/flutter_app/lib/src/studyos_tool_catalog.dart b/flutter_app/lib/src/studyos_tool_catalog.dart index d1bf946..dd4e4d1 100644 --- a/flutter_app/lib/src/studyos_tool_catalog.dart +++ b/flutter_app/lib/src/studyos_tool_catalog.dart @@ -219,6 +219,35 @@ const openSystemSettingTool = StudyOsToolSpec( required: ['setting'], ); +const createReminderTool = StudyOsToolSpec( + name: nativeCreateReminderToolName, + description: + 'Create a local reminder from an ISO-8601 timestamp. Android supports reminders, alarms, and morning routines where permitted; iOS supports one-time reminder notifications.', + traceSummary: 'Creating a native reminder.', + properties: { + 'title': { + 'type': 'string', + 'description': 'Short reminder title shown to the user.', + }, + 'time': { + 'type': 'string', + 'description': + 'ISO-8601 timestamp for the reminder, for example 2026-06-24T18:30:00+02:00.', + }, + 'type': { + 'type': 'string', + 'enum': ['REMINDER', 'ALARM', 'MORNING_ROUTINE'], + 'description': 'Optional Android reminder type. Defaults to REMINDER.', + }, + 'repeat': { + 'type': 'string', + 'enum': ['ONCE', 'DAILY', 'WEEKLY'], + 'description': 'Optional repeat cadence. Defaults to ONCE.', + }, + }, + required: ['title', 'time'], +); + const studyOsTools = [ appendMemoryTool, readMemoriesTool, @@ -234,6 +263,7 @@ const studyOsTools = [ openInstalledAppTool, searchYoutubeTool, openSystemSettingTool, + createReminderTool, ]; List studyOsToolsForNativeSupport( diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 77d4199..0f24b41 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -39,7 +39,10 @@ void main() { test('cloud tools include only supported native tools', () { final toolNames = cloudToolDefinitions( - supportedNativeToolNames: {nativeDeviceStatusToolName}, + supportedNativeToolNames: { + nativeDeviceStatusToolName, + nativeCreateReminderToolName, + }, ) .map((tool) => tool['function']) .whereType() @@ -47,6 +50,7 @@ void main() { .toList(); expect(toolNames, contains(nativeDeviceStatusToolName)); + expect(toolNames, contains(nativeCreateReminderToolName)); expect(toolNames, isNot(contains(nativeSetFlashlightToolName))); }); diff --git a/flutter_app/test/native_tool_router_test.dart b/flutter_app/test/native_tool_router_test.dart index c21da8a..16fc476 100644 --- a/flutter_app/test/native_tool_router_test.dart +++ b/flutter_app/test/native_tool_router_test.dart @@ -79,6 +79,7 @@ void main() { expect(await router.supportedToolNames(), { nativeDeviceStatusToolName, + nativeCreateReminderToolName, }); }); @@ -91,13 +92,28 @@ void main() { ); }); - test('NativeToolRouter keeps reminder inactive for #30', () async { - final router = NativeToolRouter(_FakeNativeBridge()); + test('NativeToolRouter executes create reminder when supported', () async { + final bridge = _FakeNativeBridge( + capabilities: { + 'nativeTools': >[ + { + 'name': nativeCreateReminderToolName, + 'supported': true, + }, + ], + }, + response: 'Reminder scheduled.', + ); + final router = NativeToolRouter(bridge); expect( - await router.execute(nativeCreateReminderToolName, '{}'), - 'Native tool is not available: create_reminder', + await router.execute( + nativeCreateReminderToolName, + '{"title":"Submit report","time":"2026-06-24T18:00:00+02:00"}', + ), + 'Reminder scheduled.', ); + expect(bridge.executedTool, nativeCreateReminderToolName); }); } diff --git a/flutter_app/test/studyos_tool_executor_test.dart b/flutter_app/test/studyos_tool_executor_test.dart index 0dddbc2..39ffc49 100644 --- a/flutter_app/test/studyos_tool_executor_test.dart +++ b/flutter_app/test/studyos_tool_executor_test.dart @@ -63,7 +63,7 @@ void main() { expect(toolNames, contains(nativeOpenInstalledAppToolName)); expect(toolNames, contains(nativeSearchYoutubeToolName)); expect(toolNames, contains(nativeOpenSystemSettingToolName)); - expect(toolNames, isNot(contains(nativeCreateReminderToolName))); + expect(toolNames, contains(nativeCreateReminderToolName)); }); test( @@ -92,6 +92,23 @@ void main() { 'Native tool is not available in this runtime: get_device_status', ); }); + + test( + 'StudyOsToolExecutor routes create reminder through native runner', + () async { + final nativeTools = _FakeNativeToolRunner('Reminder scheduled.'); + final executor = StudyOsToolExecutor(); + + final response = await executor.execute( + nativeCreateReminderToolName, + '{"title":"Submit report","time":"2026-06-24T18:00:00+02:00"}', + _context(nativeTools: nativeTools), + ); + + expect(response, 'Reminder scheduled.'); + expect(nativeTools.calls, [nativeCreateReminderToolName]); + }, + ); } StudyOsToolContext _context({