diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/AlarmReceiver.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/AlarmReceiver.kt new file mode 100644 index 0000000..df39995 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/AlarmReceiver.kt @@ -0,0 +1,67 @@ +package com.taskttl.core.alarm + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.taskttl.core.notification.NotificationManager +import com.taskttl.core.notification.NotificationPayload +import com.taskttl.core.notification.NotificationRepeatType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class AlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != "com.taskttl.ALARM_TRIGGER") return + + val id = intent.getStringExtra("id") ?: return + val title = intent.getStringExtra("title") ?: "TaskTTL" + val message = intent.getStringExtra("message") ?: "" + + // 使用协程调用统一通知方法 + CoroutineScope(Dispatchers.Main).launch { + NotificationManager.scheduleNotification( + NotificationPayload( + id = id, + title = title, + message = message, + triggerTimeMillis = System.currentTimeMillis(), + repeatType = NotificationRepeatType.NONE + ) + ) + } + + + + // val id = intent.getStringExtra("id") ?: return + // val title = intent.getStringExtra("title") ?: "TaskTTL" + // val message = intent.getStringExtra("message") ?: "" + // + // val channelId = "taskttl_channel" + // + // // 确保通道存在 + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + // if (nm.getNotificationChannel(channelId) == null) { + // val channel = android.app.NotificationChannel( + // channelId, + // "TaskTTL Notifications", + // android.app.NotificationManager.IMPORTANCE_HIGH + // ) + // channel.description = "TaskTTL 提醒通知" + // nm.createNotificationChannel(channel) + // } + // } + // + // // 构建通知 + // val builder = NotificationCompat.Builder(context, channelId) + // .setContentTitle(title) + // .setContentText(message) + // .setSmallIcon(R.mipmap.ic_launcher) + // .setAutoCancel(true) + // .setPriority(NotificationCompat.PRIORITY_HIGH) + // + // val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + // nm.notify(id.hashCode(), builder.build()) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/BootReceiver.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/BootReceiver.kt new file mode 100644 index 0000000..4c19b7e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/alarm/BootReceiver.kt @@ -0,0 +1,34 @@ +package com.taskttl.core.alarm + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.taskttl.core.notification.NotificationPayload + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + // 重新注册所有闹钟(可从数据库恢复) + + // 这里假设你有一个持久化存储保存了待触发通知列表 + // 例如 Room / SharedPreferences / 数据库 + // val pendingNotifications = loadPendingNotifications(context) + // + // // 使用协程重新注册通知 + // CoroutineScope(Dispatchers.Main).launch { + // pendingNotifications.forEach { payload -> + // NotificationManager.scheduleNotification(payload) + // } + // } + } + } + + /** + * 从存储中获取设备重启后需要重新注册的通知 + */ + private fun loadPendingNotifications(context: Context): List { + // TODO: 这里你需要实现真实数据恢复逻辑 + // 示例:返回一个空列表 + return emptyList() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationManager.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationManager.android.kt new file mode 100644 index 0000000..c410363 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationManager.android.kt @@ -0,0 +1,197 @@ +package com.taskttl.core.notification + +import android.Manifest +import android.app.Activity +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.RingtoneManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.work.* +import com.taskttl.MainApplication +import com.taskttl.R +import com.taskttl.core.alarm.AlarmReceiver +import com.taskttl.core.utils.LogUtils +import java.util.concurrent.TimeUnit +import kotlin.math.max + +actual object NotificationManager { + + private val channelId = "taskttl_channel" + + init { + setupNotificationChannel() + } + + private fun getActivity(): Activity = MainApplication.currentActivity() + ?: throw IllegalStateException("No activity available") + + /** 创建通知通道(Android 8+) */ + private fun setupNotificationChannel() { + val activity = MainApplication.currentActivity() ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + if (nm.getNotificationChannel(channelId) == null) { + val channel = android.app.NotificationChannel( + channelId, + "TaskTTL Notifications", + android.app.NotificationManager.IMPORTANCE_HIGH + ).apply { description = "TaskTTL 提醒通知" } + nm.createNotificationChannel(channel) + LogUtils.d("DevTTL_NotificationTest", "Notification channel created") + } + } + } + + /** 调度通知 */ + actual suspend fun scheduleNotification(payload: NotificationPayload) { + val activity = getActivity() + + // Android 13+ 权限检查 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1001 + ) + return + } + + val delay = max(0, payload.triggerTimeMillis - System.currentTimeMillis()) + if (delay == 0L) { + showImmediateNotification(payload) + return + } + + if (delay <= 24 * 60 * 60 * 1000L && payload.repeatType == NotificationRepeatType.NONE) { + scheduleExactAlarm(payload) + } else { + scheduleWorkManager(payload) + } + } + + /** AlarmManager 精确闹钟 */ + private fun scheduleExactAlarm(payload: NotificationPayload) { + val activity = getActivity() + val alarmManager = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + scheduleWorkManager(payload) // 回退 WorkManager + return + } + + val intent = Intent(activity, AlarmReceiver::class.java).apply { + action = "com.taskttl.ALARM_TRIGGER" + putExtra("id", payload.id) + putExtra("title", payload.title) + putExtra("message", payload.message) + } + + val pendingIntent = PendingIntent.getBroadcast( + activity, + payload.id.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + payload.triggerTimeMillis, + pendingIntent + ) + } + + /** WorkManager 长期/周期通知 */ + private fun scheduleWorkManager(payload: NotificationPayload) { + val activity = getActivity() + val data = workDataOf( + "id" to payload.id, + "title" to payload.title, + "message" to payload.message, + "repeatType" to payload.repeatType.name + ) + + val workManager = WorkManager.getInstance(activity) + + if (payload.repeatType == NotificationRepeatType.NONE) { + val delay = max(0, payload.triggerTimeMillis - System.currentTimeMillis()) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setInputData(data) + .addTag(payload.id) + .build() + workManager.enqueue(request) + } else { + val interval = when (payload.repeatType) { + NotificationRepeatType.DAILY -> 24 * 60 * 60 * 1000L + NotificationRepeatType.WEEKLY -> 7 * 24 * 60 * 60 * 1000L + NotificationRepeatType.MONTHLY -> 30 * 24 * 60 * 60 * 1000L + else -> 24 * 60 * 60 * 1000L + } + + val request = PeriodicWorkRequestBuilder(interval, TimeUnit.MILLISECONDS) + .setInputData(data) + .addTag(payload.id) + .build() + + workManager.enqueueUniquePeriodicWork( + payload.id, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } + } + + /** 立即显示通知 */ + private fun showImmediateNotification(payload: NotificationPayload) { + val activity = getActivity() + val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + + val notification = NotificationCompat.Builder(activity, channelId) + .setContentTitle(payload.title) + .setContentText(payload.message) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + // .setDefaults(NotificationCompat.DEFAULT_SOUND) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .build() + + nm.notify(payload.id.hashCode(), notification) + } + + + /** 取消通知 */ + actual suspend fun cancelNotification(id: String) { + val activity = getActivity() + WorkManager.getInstance(activity).cancelAllWorkByTag(id) + + val alarmManager = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(activity, AlarmReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + activity, + id.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.cancel(pendingIntent) + + val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + nm.cancel(id.hashCode()) + } + + actual suspend fun cancelAll() { + val activity = getActivity() + WorkManager.getInstance(activity).cancelAllWork() + val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + nm.cancelAll() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.android.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.android.kt new file mode 100644 index 0000000..1737daa --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.android.kt @@ -0,0 +1,123 @@ +package com.taskttl.core.notification + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.taskttl.MainApplication +import androidx.core.content.edit + +actual object NotificationPermissionManager { + private const val REQUEST_CODE = 2025 + private var callback: NotificationPermissionCallback? = null + + /** + * 在 Activity 的 onRequestPermissionsResult 中调用 + */ + fun handlePermissionResult(requestCode: Int, grantResults: IntArray) { + if (requestCode == REQUEST_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + callback?.onGranted() + } else { + callback?.onDenied() + } + } + } + + // expect/actual 适配:Android 需要 Activity 参数 + actual fun requestPermission(callback: NotificationPermissionCallback) { + // Android 13 以下无需权限 + if (Build.VERSION.SDK_INT < 33) { + callback.onGranted() + return + } + + val activity = MainApplication.currentActivity() + if (activity == null) { + Log.w("NotificationPermission", "No current activity found") + callback.onDenied() + return + } + this.callback = callback + + val prefs = activity.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + + val permission = Manifest.permission.POST_NOTIFICATIONS + val granted = ContextCompat.checkSelfPermission( + activity, permission + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + callback.onGranted() + } else { + val isFirstRequest = prefs.getBoolean("notification_first_request", true) + val shouldShowRationale = + ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + + if (!shouldShowRationale && !isFirstRequest) { + openNotificationSettings() + callback.onDenied() + } else { + ActivityCompat.requestPermissions(activity, arrayOf(permission), REQUEST_CODE) + } + } + + prefs.edit { putBoolean("notification_first_request", false) } + } + + + actual fun verifyPermission(): Boolean { + // Android 13 以下无需权限 + if (Build.VERSION.SDK_INT < 33) { + return true + } + val activity = MainApplication.currentActivity() + if (activity == null) { + Log.w("NotificationPermission", "No current activity found") + return false + } + val permission = Manifest.permission.POST_NOTIFICATIONS + val granted = ContextCompat.checkSelfPermission( + activity, permission + ) == PackageManager.PERMISSION_GRANTED + return granted + } + + /** + * 引导用户到系统设置页关闭通知权限 + */ + actual fun disablePermission() { + openNotificationSettings() + } + + + private fun openNotificationSettings() { + val context = MainApplication.currentActivity() ?: MainApplication.instance + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // API 26 及以上,打开通知设置页 + Intent().apply { + action = android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS + putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName) + } + } else { + // API 26 以下,打开应用详情页 + Intent().apply { + action = android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = "package:${context.packageName}".toUri() + } + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + context.startActivity(intent) + Log.d("NotificationPermission", "打开通知设置页") + } catch (e: Exception) { + Log.e("NotificationPermission", "无法打开通知设置页: ${e.message}") + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationWorker.kt b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationWorker.kt new file mode 100644 index 0000000..64f1cdf --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/taskttl/core/notification/NotificationWorker.kt @@ -0,0 +1,54 @@ +package com.taskttl.core.notification + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.taskttl.MainActivity +import com.taskttl.R + +class NotificationWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val id = inputData.getString("id") ?: return Result.failure() + val title = inputData.getString("title") ?: "TaskTTL" + val message = inputData.getString("message") ?: "" + + sendNotification(id, title, message) + return Result.success() + } + + private fun sendNotification(id: String, title: String, message: String) { + val context = applicationContext + val channelId = "taskttl_channel" + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = PendingIntent.getActivity( + context, + id.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, channelId) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + nm.notify(id.hashCode(), builder.build()) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationManager.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationManager.kt new file mode 100644 index 0000000..324c9cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationManager.kt @@ -0,0 +1,27 @@ +package com.taskttl.core.notification + +/** + * 通知管理器 + * @author admin + * @date 2025/10/16 + * @constructor 创建[NotificationManager] + */ +expect object NotificationManager { + /** + * 日程通知 + * @param [payload] 有效载荷 + */ + suspend fun scheduleNotification(payload: NotificationPayload) + + /** + * 取消通知 + * @param [id] ID + */ + suspend fun cancelNotification(id: String) + + /** + * 全部取消 + */ + suspend fun cancelAll() + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPayload.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPayload.kt new file mode 100644 index 0000000..194ba43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPayload.kt @@ -0,0 +1,20 @@ +package com.taskttl.core.notification + +/** + * 通知有效载荷 + * @author admin + * @date 2025/10/16 + * @constructor 创建[NotificationPayload] + * @param [id] ID + * @param [title] 标题 + * @param [message] 消息 + * @param [triggerTimeMillis] 触发时间毫秒 + * @param [repeatType] 重复类型 + */ +data class NotificationPayload( + val id: String, + val title: String, + val message: String, + val triggerTimeMillis: Long, + val repeatType: NotificationRepeatType = NotificationRepeatType.NONE, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.kt new file mode 100644 index 0000000..50a7133 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.kt @@ -0,0 +1,31 @@ +package com.taskttl.core.notification + +/** + * 通知权限回调接口 + */ +interface NotificationPermissionCallback { + /** + * 授予 + */ + fun onGranted() + + /** + * 被否认 + */ + fun onDenied() +} + +/** + * 跨平台通知权限处理器 + */ +expect object NotificationPermissionManager { + /** + * 请求通知权限 + * @return 是否允许 + */ + fun requestPermission(callback: NotificationPermissionCallback) + + fun verifyPermission(): Boolean + + fun disablePermission() +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationRepeatType.kt b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationRepeatType.kt new file mode 100644 index 0000000..740ad51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/taskttl/core/notification/NotificationRepeatType.kt @@ -0,0 +1,14 @@ +package com.taskttl.core.notification + +/** + * 通知重复类型 + * @author admin + * @date 2025/10/16 + * @constructor 创建[NotificationRepeatType] + */ +enum class NotificationRepeatType { + NONE, + DAILY, + WEEKLY, + MONTHLY +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationManager.ios.kt b/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationManager.ios.kt new file mode 100644 index 0000000..7271b29 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationManager.ios.kt @@ -0,0 +1,43 @@ +package com.taskttl.core.notification + +import platform.UserNotifications.* + +actual class NotificationManager { + actual suspend fun scheduleNotification(payload: NotificationPayload) { + val content = UNMutableNotificationContent().apply { + title = payload.title + body = payload.message + sound = UNNotificationSound.defaultSound() + } + + val interval = (payload.triggerTimeMillis - getCurrentTimeMillis()) / 1000.0 + val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval( + interval, + repeats = payload.repeatType != NotificationRepeatType.NONE + ) + + val request = UNNotificationRequest.requestWithIdentifier( + payload.id, + content, + trigger + ) + + UNUserNotificationCenter.currentNotificationCenter() + .addNotificationRequest(request) { error -> + error?.let { println("❌ Notification Error: ${it.localizedDescription}") } + } + } + + actual suspend fun cancelNotification(id: String) { + UNUserNotificationCenter.currentNotificationCenter() + .removePendingNotificationRequestsWithIdentifiers(listOf(id)) + } + + actual suspend fun cancelAll() { + UNUserNotificationCenter.currentNotificationCenter() + .removeAllPendingNotificationRequests() + } + + private fun getCurrentTimeMillis(): Long = + (platform.Foundation.NSDate().timeIntervalSince1970 * 1000).toLong() +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.ios.kt b/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.ios.kt new file mode 100644 index 0000000..138aa29 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/taskttl/core/notification/NotificationPermissionHandler.ios.kt @@ -0,0 +1,14 @@ +package com.taskttl.core.notification + +import platform.UserNotifications.* + +actual object NotificationPermissionManager { + actual fun requestPermission(callback: NotificationPermissionCallback) { + val center = UNUserNotificationCenter.currentNotificationCenter() + center.requestAuthorizationWithOptions( + UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge + ) { granted, _ -> + if (granted) callback.onGranted() else callback.onDenied() + } + } +} \ No newline at end of file