Skip to content

Commit 487749e

Browse files
authored
Merge pull request #371 from DevKor-github/codex/native-alarm-client
[codex] add native alarm client
2 parents 39b3cc6 + e92956d commit 487749e

78 files changed

Lines changed: 5580 additions & 245 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

analysis_options.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
include: package:flutter_lints/flutter.yaml
1111

1212
analyzer:
13-
13+
exclude:
14+
- widgetbook/**
15+
1416
linter:
1517
# The lint rules applied to this project can be customized in the
1618
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

android/app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ android {
3131
targetSdk = flutter.targetSdkVersion
3232
versionCode = flutter.versionCode
3333
versionName = flutter.versionName
34+
manifestPlaceholders = [
35+
appAuthRedirectScheme: applicationId
36+
]
3437
}
3538

3639
buildTypes {

android/app/src/main/AndroidManifest.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
22
<!-- Android 13(API 33) 이상 알림 권한 -->
33
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
4+
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
5+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
46

57
<application
68
android:label="on_time_front"
@@ -34,6 +36,14 @@
3436
<meta-data
3537
android:name="flutterEmbedding"
3638
android:value="2" />
39+
<receiver
40+
android:name=".NativeAlarmBootReceiver"
41+
android:enabled="true"
42+
android:exported="false">
43+
<intent-filter>
44+
<action android:name="android.intent.action.BOOT_COMPLETED"/>
45+
</intent-filter>
46+
</receiver>
3747
</application>
3848
<!-- Required to query activities that can process text, see:
3949
https://developer.android.com/training/package-visibility and
Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,145 @@
11
package com.example.on_time_front
22

3+
import android.app.AlarmManager
4+
import android.app.PendingIntent
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.os.Bundle
38
import io.flutter.embedding.android.FlutterActivity
9+
import io.flutter.embedding.engine.FlutterEngine
10+
import io.flutter.plugin.common.MethodCall
11+
import io.flutter.plugin.common.MethodChannel
412

5-
class MainActivity: FlutterActivity()
13+
class MainActivity : FlutterActivity() {
14+
private var methodChannel: MethodChannel? = null
15+
16+
companion object {
17+
private const val CHANNEL_NAME = "on_time_front/native_alarm"
18+
const val ACTION_SCHEDULE_ALARM = "on_time_front.SCHEDULE_ALARM"
19+
private var launchPayload: Map<String, String>? = null
20+
}
21+
22+
override fun onCreate(savedInstanceState: Bundle?) {
23+
super.onCreate(savedInstanceState)
24+
payloadFromIntent(intent)?.let { launchPayload = it }
25+
}
26+
27+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
28+
super.configureFlutterEngine(flutterEngine)
29+
methodChannel = MethodChannel(
30+
flutterEngine.dartExecutor.binaryMessenger,
31+
CHANNEL_NAME,
32+
)
33+
methodChannel?.setMethodCallHandler { call, result ->
34+
when (call.method) {
35+
"getCapabilities" -> result.success(
36+
mapOf(
37+
"supportsNativeAlarm" to true,
38+
"nativeAlarmProvider" to "androidAlarmManager",
39+
"fallbackProvider" to "localNotification",
40+
),
41+
)
42+
"checkPermission" -> result.success("granted")
43+
"requestPermission" -> result.success("granted")
44+
"scheduleNativeAlarm" -> scheduleNativeAlarm(call, result)
45+
"cancelNativeAlarm" -> cancelNativeAlarm(call, result)
46+
"getLaunchPayload" -> {
47+
result.success(launchPayload)
48+
launchPayload = null
49+
}
50+
else -> result.notImplemented()
51+
}
52+
}
53+
}
54+
55+
override fun onNewIntent(intent: Intent) {
56+
super.onNewIntent(intent)
57+
setIntent(intent)
58+
payloadFromIntent(intent)?.let {
59+
launchPayload = it
60+
methodChannel?.invokeMethod("alarmLaunch", it)
61+
}
62+
}
63+
64+
private fun scheduleNativeAlarm(call: MethodCall, result: MethodChannel.Result) {
65+
val args = call.arguments as? Map<*, *>
66+
if (args == null) {
67+
result.error("invalidArguments", "Missing alarm arguments", null)
68+
return
69+
}
70+
71+
val triggerAtMillis = (args["alarmTime"] as? Number)?.toLong()
72+
val scheduleId = args["scheduleId"]?.toString()
73+
if (triggerAtMillis == null || scheduleId.isNullOrEmpty()) {
74+
result.error("invalidArguments", "Missing scheduleId or alarmTime", null)
75+
return
76+
}
77+
if (triggerAtMillis <= System.currentTimeMillis()) {
78+
result.error("invalidArguments", "Cannot schedule a past alarm", null)
79+
return
80+
}
81+
82+
val alarmManager = getSystemService(Context.ALARM_SERVICE) as? AlarmManager
83+
if (alarmManager == null) {
84+
result.error("unsupported", "AlarmManager is unavailable", null)
85+
return
86+
}
87+
88+
val pendingIntent = pendingIntentFor(args, PendingIntent.FLAG_UPDATE_CURRENT)
89+
val alarmClockInfo = AlarmManager.AlarmClockInfo(triggerAtMillis, pendingIntent)
90+
alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)
91+
result.success(null)
92+
}
93+
94+
private fun cancelNativeAlarm(call: MethodCall, result: MethodChannel.Result) {
95+
val args = call.arguments as? Map<*, *>
96+
if (args == null) {
97+
result.success(null)
98+
return
99+
}
100+
val alarmManager = getSystemService(Context.ALARM_SERVICE) as? AlarmManager
101+
val pendingIntent = pendingIntentFor(args, PendingIntent.FLAG_NO_CREATE)
102+
if (alarmManager != null && pendingIntent != null) {
103+
alarmManager.cancel(pendingIntent)
104+
pendingIntent.cancel()
105+
}
106+
result.success(null)
107+
}
108+
109+
private fun pendingIntentFor(args: Map<*, *>, lookupFlag: Int): PendingIntent? {
110+
val requestCode = (args["nativeAlarmId"] as? Number)?.toInt()
111+
?: args["scheduleId"].toString().hashCode()
112+
val intent = Intent(this, MainActivity::class.java).apply {
113+
action = ACTION_SCHEDULE_ALARM
114+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
115+
Intent.FLAG_ACTIVITY_CLEAR_TOP or
116+
Intent.FLAG_ACTIVITY_SINGLE_TOP
117+
putExtra("type", "schedule_alarm")
118+
putExtra("scheduleId", args["scheduleId"]?.toString())
119+
putExtra("promptVariant", "alarm")
120+
putExtra("alarmTime", args["alarmTime"]?.toString())
121+
putExtra("preparationStartTime", args["preparationStartTime"]?.toString())
122+
123+
val payload = args["payload"] as? Map<*, *>
124+
payload?.forEach { (key, value) ->
125+
if (key != null && value != null) {
126+
putExtra(key.toString(), value.toString())
127+
}
128+
}
129+
}
130+
val flags = lookupFlag or PendingIntent.FLAG_IMMUTABLE
131+
return PendingIntent.getActivity(this, requestCode, intent, flags)
132+
}
133+
134+
private fun payloadFromIntent(intent: Intent?): Map<String, String>? {
135+
if (intent?.action != ACTION_SCHEDULE_ALARM) return null
136+
val extras = intent.extras ?: return null
137+
val payload = mutableMapOf<String, String>()
138+
for (key in extras.keySet()) {
139+
extras.get(key)?.let { payload[key] = it.toString() }
140+
}
141+
payload["type"] = "schedule_alarm"
142+
payload["promptVariant"] = "alarm"
143+
return payload
144+
}
145+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.example.on_time_front
2+
3+
import android.app.AlarmManager
4+
import android.app.PendingIntent
5+
import android.content.BroadcastReceiver
6+
import android.content.Context
7+
import android.content.Intent
8+
import java.text.SimpleDateFormat
9+
import java.util.Locale
10+
import java.util.TimeZone
11+
import org.json.JSONArray
12+
import org.json.JSONObject
13+
14+
class NativeAlarmBootReceiver : BroadcastReceiver() {
15+
companion object {
16+
private const val NATIVE_PREF_NAME = "on_time_native_alarm"
17+
private const val FLUTTER_PREF_NAME = "FlutterSharedPreferences"
18+
private const val REGISTRY_PREF_KEY = "flutter.scheduled_alarm_registry"
19+
}
20+
21+
override fun onReceive(context: Context, intent: Intent) {
22+
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
23+
restorePersistedNativeAlarms(context)
24+
context.getSharedPreferences(NATIVE_PREF_NAME, Context.MODE_PRIVATE)
25+
.edit()
26+
.putBoolean("boot_completed_since_last_launch", true)
27+
.apply()
28+
}
29+
30+
private fun restorePersistedNativeAlarms(context: Context) {
31+
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
32+
val rawRegistry = context.getSharedPreferences(FLUTTER_PREF_NAME, Context.MODE_PRIVATE)
33+
.getString(REGISTRY_PREF_KEY, null)
34+
?: return
35+
val now = System.currentTimeMillis()
36+
val records = try {
37+
JSONArray(rawRegistry)
38+
} catch (_: Exception) {
39+
return
40+
}
41+
42+
for (index in 0 until records.length()) {
43+
val record = records.optJSONObject(index) ?: continue
44+
if (record.optString("provider") != "androidAlarmManager") continue
45+
val scheduleId = record.optString("scheduleId")
46+
if (scheduleId.isEmpty()) continue
47+
val alarmTime = parseAlarmTime(record.optString("alarmTime")) ?: continue
48+
if (alarmTime <= now) continue
49+
val pendingIntent = pendingIntentFor(context, record)
50+
val alarmClockInfo = AlarmManager.AlarmClockInfo(alarmTime, pendingIntent)
51+
alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)
52+
}
53+
}
54+
55+
private fun pendingIntentFor(context: Context, record: JSONObject): PendingIntent {
56+
val scheduleId = record.optString("scheduleId")
57+
val requestCode = if (record.has("nativeAlarmId") && !record.isNull("nativeAlarmId")) {
58+
record.optInt("nativeAlarmId")
59+
} else {
60+
scheduleId.hashCode()
61+
}
62+
val alarmTimeMillis = parseAlarmTime(record.optString("alarmTime"))
63+
val preparationStartTimeMillis = parseAlarmTime(record.optString("preparationStartTime"))
64+
val payload = record.optJSONObject("payload")
65+
val intent = Intent(context, MainActivity::class.java).apply {
66+
action = MainActivity.ACTION_SCHEDULE_ALARM
67+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
68+
Intent.FLAG_ACTIVITY_CLEAR_TOP or
69+
Intent.FLAG_ACTIVITY_SINGLE_TOP
70+
putExtra("type", "schedule_alarm")
71+
putExtra("scheduleId", scheduleId)
72+
putExtra("promptVariant", "alarm")
73+
if (alarmTimeMillis != null) putExtra("alarmTime", alarmTimeMillis.toString())
74+
if (preparationStartTimeMillis != null) {
75+
putExtra("preparationStartTime", preparationStartTimeMillis.toString())
76+
}
77+
val payloadKeys = payload?.keys()
78+
while (payloadKeys?.hasNext() == true) {
79+
val key = payloadKeys.next()
80+
payload.opt(key)?.let { value -> putExtra(key, value.toString()) }
81+
}
82+
}
83+
return PendingIntent.getActivity(
84+
context,
85+
requestCode,
86+
intent,
87+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
88+
)
89+
}
90+
91+
private fun parseAlarmTime(value: String?): Long? {
92+
if (value.isNullOrBlank()) return null
93+
value.toLongOrNull()?.let { return it }
94+
val normalizedValue = value.replace(Regex("\\.(\\d{3})\\d+"), ".$1")
95+
val patterns = listOf(
96+
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
97+
"yyyy-MM-dd'T'HH:mm:ss'Z'",
98+
"yyyy-MM-dd'T'HH:mm:ss.SSS",
99+
"yyyy-MM-dd'T'HH:mm:ss",
100+
)
101+
for (pattern in patterns) {
102+
try {
103+
val format = SimpleDateFormat(pattern, Locale.US)
104+
if (pattern.endsWith("'Z'")) {
105+
format.timeZone = TimeZone.getTimeZone("UTC")
106+
}
107+
return format.parse(normalizedValue)?.time
108+
} catch (_: Exception) {
109+
continue
110+
}
111+
}
112+
return null
113+
}
114+
}

0 commit comments

Comments
 (0)