Skip to content

Commit 4271b3c

Browse files
TinyKittenclaude
andauthored
Android 16のLive Update(ProgressStyle通知)に対応 (#5387)
* Android 16のLive Update(ProgressStyle通知)に対応 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz * boundStationNameの「方面」二重付与と英語混在を修正 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz * LiveUpdateModuleのnull安全性を強化 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz * ReadableMapの防御的な拡張関数を追加してキー欠損時のクラッシュを防止 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz * 通知のcontentTextに種別+方面、subTextに路線名のみを表示するよう変更 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz * ProgressStyleのトラッカーアイコンをラインカラー塗りつぶし+白ボーダーの円に変更 https://claude.ai/code/session_016hTeobTKcEKjphKCN5p9nz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4cf29e commit 4271b3c

File tree

7 files changed

+249
-7
lines changed

7 files changed

+249
-7
lines changed

android/app/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ android {
8686
ndkVersion rootProject.ext.ndkVersion
8787

8888
buildToolsVersion rootProject.ext.buildToolsVersion
89-
compileSdk rootProject.ext.compileSdkVersion
89+
// Android 16 (API 36) の Notification.ProgressStyle を使用するため最低 36 が必要
90+
compileSdk Math.max(rootProject.ext.compileSdkVersion as int, 36)
9091

9192
namespace 'me.tinykitten.trainlcd'
9293
defaultConfig {
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package me.tinykitten.trainlcd
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.PendingIntent
7+
import android.content.Context
8+
import android.graphics.Bitmap
9+
import android.graphics.Canvas
10+
import android.graphics.Color
11+
import android.graphics.Paint
12+
import android.graphics.drawable.Icon
13+
import android.os.Build
14+
import com.facebook.react.bridge.ReactApplicationContext
15+
import com.facebook.react.bridge.ReactContextBaseJavaModule
16+
import com.facebook.react.bridge.ReactMethod
17+
import com.facebook.react.bridge.ReadableMap
18+
19+
private fun ReadableMap.optString(key: String, default: String = ""): String =
20+
if (hasKey(key) && !isNull(key)) getString(key) ?: default else default
21+
22+
private fun ReadableMap.optBoolean(key: String, default: Boolean = false): Boolean =
23+
if (hasKey(key) && !isNull(key)) getBoolean(key) else default
24+
25+
private fun ReadableMap.optDouble(key: String, default: Double = 0.0): Double =
26+
if (hasKey(key) && !isNull(key)) getDouble(key) else default
27+
28+
class LiveUpdateModule(reactContext: ReactApplicationContext) :
29+
ReactContextBaseJavaModule(reactContext) {
30+
31+
companion object {
32+
private const val CHANNEL_ID = "live_update"
33+
private const val NOTIFICATION_ID = 49152
34+
private const val MAX_PROGRESS = 1000
35+
}
36+
37+
override fun getName() = "LiveUpdateModule"
38+
39+
private fun getNotificationManager(): NotificationManager =
40+
reactApplicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
41+
42+
private fun ensureChannel() {
43+
val nm = getNotificationManager()
44+
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
45+
46+
val channel = NotificationChannel(
47+
CHANNEL_ID,
48+
"運行情報",
49+
NotificationManager.IMPORTANCE_DEFAULT
50+
).apply {
51+
description = "現在の運行状況をリアルタイムで表示します"
52+
setShowBadge(false)
53+
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
54+
}
55+
nm.createNotificationChannel(channel)
56+
}
57+
58+
private fun createContentIntent(): PendingIntent? {
59+
val intent = reactApplicationContext.packageManager
60+
.getLaunchIntentForPackage(reactApplicationContext.packageName) ?: return null
61+
return PendingIntent.getActivity(
62+
reactApplicationContext,
63+
0,
64+
intent,
65+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
66+
)
67+
}
68+
69+
@ReactMethod
70+
fun startLiveUpdate(state: ReadableMap?) {
71+
if (Build.VERSION.SDK_INT < 36 || state == null) return
72+
postProgressNotification(state)
73+
}
74+
75+
@ReactMethod
76+
fun updateLiveUpdate(state: ReadableMap?) {
77+
if (Build.VERSION.SDK_INT < 36 || state == null) return
78+
postProgressNotification(state)
79+
}
80+
81+
@ReactMethod
82+
fun stopLiveUpdate() {
83+
getNotificationManager().cancel(NOTIFICATION_ID)
84+
}
85+
86+
private fun createTrackerIcon(color: Int): Icon {
87+
val density = reactApplicationContext.resources.displayMetrics.density
88+
val sizePx = (24 * density).toInt()
89+
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
90+
val canvas = Canvas(bitmap)
91+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
92+
val cx = sizePx / 2f
93+
val cy = sizePx / 2f
94+
val strokeWidth = 2f * density
95+
val radius = cx - strokeWidth / 2f
96+
97+
paint.style = Paint.Style.FILL
98+
paint.color = color
99+
canvas.drawCircle(cx, cy, radius, paint)
100+
101+
paint.style = Paint.Style.STROKE
102+
paint.color = Color.WHITE
103+
paint.strokeWidth = strokeWidth
104+
canvas.drawCircle(cx, cy, radius, paint)
105+
106+
return Icon.createWithBitmap(bitmap)
107+
}
108+
109+
@Suppress("NewApi")
110+
private fun postProgressNotification(state: ReadableMap) {
111+
ensureChannel()
112+
113+
val stationName = state.optString("stationName")
114+
val nextStationName = state.optString("nextStationName")
115+
val approaching = state.optBoolean("approaching")
116+
val stopped = state.optBoolean("stopped")
117+
val lineName = state.optString("lineName")
118+
val lineColor = state.optString("lineColor", "#000000")
119+
val progress = state.optDouble("progress")
120+
val trainTypeName = state.optString("trainTypeName")
121+
val boundStationName = state.optString("boundStationName")
122+
val passingStationName = state.optString("passingStationName")
123+
124+
val parsedColor = try {
125+
Color.parseColor(lineColor)
126+
} catch (_: Exception) {
127+
Color.BLACK
128+
}
129+
130+
val progressInt = (progress * MAX_PROGRESS).toInt().coerceIn(0, MAX_PROGRESS)
131+
132+
val contentTitle = when {
133+
passingStationName.isNotEmpty() -> "$passingStationName 通過中"
134+
stopped -> stationName
135+
approaching -> "まもなく $nextStationName"
136+
else -> "$stationName$nextStationName"
137+
}
138+
139+
val contentText = buildString {
140+
if (trainTypeName.isNotEmpty()) {
141+
append(trainTypeName)
142+
append(" ")
143+
}
144+
append(boundStationName)
145+
}
146+
147+
val subText = lineName
148+
149+
val trackerIcon = createTrackerIcon(parsedColor)
150+
151+
val progressStyle = Notification.ProgressStyle()
152+
.setStyledByProgress(true)
153+
.setProgress(progressInt)
154+
.setProgressTrackerIcon(trackerIcon)
155+
.setProgressSegments(
156+
listOf(
157+
Notification.ProgressStyle.Segment(MAX_PROGRESS).setColor(parsedColor)
158+
)
159+
)
160+
.setProgressPoints(
161+
listOf(
162+
Notification.ProgressStyle.Point(0).setColor(parsedColor),
163+
Notification.ProgressStyle.Point(MAX_PROGRESS).setColor(parsedColor)
164+
)
165+
)
166+
167+
val builder = Notification.Builder(reactApplicationContext, CHANNEL_ID)
168+
.setSmallIcon(R.drawable.ic_notification_live_update)
169+
.setContentTitle(contentTitle)
170+
.setContentText(contentText)
171+
.setSubText(subText)
172+
.setStyle(progressStyle)
173+
.setOngoing(true)
174+
.setOnlyAlertOnce(true)
175+
176+
createContentIntent()?.let { builder.setContentIntent(it) }
177+
178+
val notification = builder.build()
179+
180+
getNotificationManager().notify(NOTIFICATION_ID, notification)
181+
}
182+
}

android/app/src/main/java/me/tinykitten/trainlcd/TrainLCDPackage.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ class TrainLCDPackage : ReactPackage {
1919
): MutableList<NativeModule> = listOf(
2020
WearableModule(reactContext),
2121
IgnoreBatteryOptimizationsModule(reactContext),
22-
GnssModule(reactContext)
22+
GnssModule(reactContext),
23+
LiveUpdateModule(reactContext)
2324
).toMutableList()
2425
}
2526

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="#FFFFFF"
8+
android:pathData="M12,2c-4,0 -8,0.5 -8,4v9.5C4,17.43 5.57,19 7.5,19L6,20.5v0.5h2l2,-2h4l2,2h2v-0.5L16.5,19c1.93,0 3.5,-1.57 3.5,-3.5V6c0,-3.5 -3.58,-4 -8,-4zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM11,10L6,10V6h5v4zM13,10v-4h5v4h-5zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
9+
</vector>

src/constants/native.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@ import { Platform } from 'react-native';
22

33
export const IS_LIVE_ACTIVITIES_ELIGIBLE_PLATFORM =
44
Platform.OS === 'ios' && Number.parseFloat(Platform.Version) >= 16.1;
5+
6+
export const IS_LIVE_UPDATE_ELIGIBLE_PLATFORM =
7+
Platform.OS === 'android' && (Platform.Version as number) >= 36;

src/hooks/useUpdateLiveActivities.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { directionToDirectionName } from '../models/Bound';
66
import stationState from '../store/atoms/station';
77
import { isJapanese } from '../translation';
88
import getIsPass from '../utils/isPass';
9+
import {
10+
startLiveUpdate,
11+
stopLiveUpdate,
12+
updateLiveUpdate,
13+
} from '../utils/native/android/liveUpdateModule';
914
import {
1015
startLiveActivity,
1116
stopLiveActivity,
@@ -82,12 +87,12 @@ export const useUpdateLiveActivities = (): void => {
8287
]);
8388

8489
const boundStationName = useMemo(() => {
85-
const jaSuffix = isFullLoopLine || isPartiallyLoopLine ? '方面' : '';
86-
87-
return `${directionalStops
90+
const names = directionalStops
8891
.map((s) => (isJapanese ? s.name : s.nameRoman))
89-
.join(isJapanese ? '・' : '/')}${isJapanese ? jaSuffix : ''}`;
90-
}, [directionalStops, isFullLoopLine, isPartiallyLoopLine]);
92+
.join(isJapanese ? '・' : '/');
93+
94+
return isJapanese ? `${names}方面` : names;
95+
}, [directionalStops]);
9196

9297
const boundStationNumber = useMemo(() => {
9398
return directionalStops
@@ -236,20 +241,23 @@ export const useUpdateLiveActivities = (): void => {
236241
useEffect(() => {
237242
if (selectedBound && !started) {
238243
startLiveActivity(activityState);
244+
startLiveUpdate(activityState);
239245
setStarted(true);
240246
}
241247
}, [activityState, selectedBound, started]);
242248

243249
useEffect(() => {
244250
return () => {
245251
stopLiveActivity();
252+
stopLiveUpdate();
246253
setStarted(false);
247254
};
248255
}, []);
249256

250257
useEffect(() => {
251258
if (started) {
252259
updateLiveActivity(activityState);
260+
updateLiveUpdate(activityState);
253261
}
254262
}, [activityState, started]);
255263
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { NativeModules } from 'react-native';
2+
import { IS_LIVE_UPDATE_ELIGIBLE_PLATFORM } from '../../../constants';
3+
4+
const { LiveUpdateModule } = NativeModules;
5+
6+
type LiveUpdateState = {
7+
stationName: string;
8+
nextStationName: string;
9+
stationNumber: string;
10+
nextStationNumber: string;
11+
approaching: boolean;
12+
stopped: boolean;
13+
lineName: string;
14+
lineColor: string;
15+
passingStationName: string;
16+
passingStationNumber: string;
17+
progress: number;
18+
boundStationName: string;
19+
trainTypeName: string;
20+
};
21+
22+
export const startLiveUpdate = (state?: LiveUpdateState) => {
23+
if (IS_LIVE_UPDATE_ELIGIBLE_PLATFORM) {
24+
LiveUpdateModule?.startLiveUpdate?.(state);
25+
}
26+
};
27+
28+
export const updateLiveUpdate = (state: LiveUpdateState) => {
29+
if (IS_LIVE_UPDATE_ELIGIBLE_PLATFORM) {
30+
LiveUpdateModule?.updateLiveUpdate?.(state);
31+
}
32+
};
33+
34+
export const stopLiveUpdate = () => {
35+
if (IS_LIVE_UPDATE_ELIGIBLE_PLATFORM) {
36+
LiveUpdateModule?.stopLiveUpdate?.();
37+
}
38+
};

0 commit comments

Comments
 (0)