Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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;
}
Expand Down Expand Up @@ -197,4 +202,4 @@ private void save(JSONArray arr) {
fos.write(arr.toString().getBytes());
} catch (Exception ignored) {}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(),
),
)
}
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 <reified T : Enum<T>> enumArgument(
arguments: Map<*, *>,
key: String,
defaultValue: T,
): T {
val value = arguments[key]?.toString()?.trim()
if (value.isNullOrEmpty()) return defaultValue
return runCatching {
enumValueOf<T>(value.uppercase(Locale.US))
}.getOrElse {
throw IllegalArgumentException(
"Unsupported '$key' value '$value'.",
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 57 additions & 3 deletions flutter_app/ios/Runner/StudyOSNativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -222,13 +226,63 @@ 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).",
details: nil
))
}

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",
Expand Down Expand Up @@ -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]
]
}

Expand Down
Loading
Loading