Skip to content

Commit a44c3d0

Browse files
committed
v0.5.0: 通知栏 / 灵动岛计时(实验性)
- 会话进行中发一条常驻通知:所有机型的通知栏/锁屏显示自走秒计时 (chronometer),文字每分钟刷新 - 附带 HyperOS 焦点通知/超级岛参数(miui.focus.param,协议参考小米 官方与阿里云 EMAS 公开文档):系统授予焦点通知权限时渲染为灵动岛, 无权限时按普通通知优雅降级 - 新增开关「通知栏 / 灵动岛计时」(默认开),Android 13+ 首次开启时 请求通知权限
1 parent cde00d9 commit a44c3d0

8 files changed

Lines changed: 170 additions & 2 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ android {
2121
applicationId = "com.snownamida.touchgrass"
2222
minSdk = 26
2323
targetSdk = 34
24-
versionCode = 7
25-
versionName = "0.4.2"
24+
versionCode = 8
25+
versionName = "0.5.0"
2626
}
2727

2828
signingConfigs {

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
android:name="android.permission.QUERY_ALL_PACKAGES"
88
tools:ignore="QueryAllPackagesPermission" />
99

10+
<!-- 会话计时的常驻通知(含 HyperOS 焦点通知/灵动岛) -->
11+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
12+
1013
<application
1114
android:name=".App"
1215
android:allowBackup="true"

app/src/main/java/com/snownamida/touchgrass/AppState.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ object AppState {
2323
WatcherService.instance?.applyMode(mode)
2424
}
2525

26+
private const val KEY_NOTIF = "notifTimer"
27+
28+
fun isNotifTimer(context: Context): Boolean =
29+
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_NOTIF, true)
30+
31+
fun setNotifTimer(context: Context, enabled: Boolean) {
32+
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
33+
.edit().putBoolean(KEY_NOTIF, enabled).apply()
34+
}
35+
2636
private const val KEY_DEBUG = "debugOverlay"
2737

2838
fun isDebug(context: Context): Boolean =

app/src/main/java/com/snownamida/touchgrass/MainActivity.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.snownamida.touchgrass
22

3+
import android.Manifest
34
import android.content.ClipData
45
import android.content.ClipboardManager
56
import android.content.ComponentName
67
import android.content.Intent
8+
import android.content.pm.PackageManager
9+
import android.os.Build
710
import android.os.Bundle
811
import android.provider.Settings
912
import android.view.LayoutInflater
@@ -32,6 +35,10 @@ class MainActivity : AppCompatActivity() {
3235

3336
private var suppressMode = false
3437
private var suppressDebug = false
38+
private var suppressNotif = false
39+
40+
private val notifPermLauncher =
41+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
3542

3643
private val exportLauncher =
3744
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
@@ -140,6 +147,16 @@ class MainActivity : AppCompatActivity() {
140147
refresh()
141148
}
142149

150+
findViewById<MaterialSwitch>(R.id.switch_notif).setOnCheckedChangeListener { _, checked ->
151+
if (suppressNotif) return@setOnCheckedChangeListener
152+
AppState.setNotifTimer(this, checked)
153+
if (checked && Build.VERSION.SDK_INT >= 33 &&
154+
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
155+
) {
156+
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
157+
}
158+
}
159+
143160
findViewById<MaterialSwitch>(R.id.switch_debug).setOnCheckedChangeListener { _, checked ->
144161
if (!suppressDebug) AppState.setDebug(this, checked)
145162
}
@@ -217,6 +234,10 @@ class MainActivity : AppCompatActivity() {
217234
findViewById<MaterialSwitch>(R.id.switch_debug).isChecked = AppState.isDebug(this)
218235
suppressDebug = false
219236

237+
suppressNotif = true
238+
findViewById<MaterialSwitch>(R.id.switch_notif).isChecked = AppState.isNotifTimer(this)
239+
suppressNotif = false
240+
220241
val rules = RuleStore.load(this)
221242
val limits = LimitConfig.load(this)
222243

app/src/main/java/com/snownamida/touchgrass/track/SessionTracker.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.snownamida.touchgrass.track
33
import android.os.Handler
44
import android.os.Looper
55
import android.os.SystemClock
6+
import com.snownamida.touchgrass.AppState
67
import com.snownamida.touchgrass.debug.DebugLog
78
import com.snownamida.touchgrass.rules.Rule
89
import com.snownamida.touchgrass.service.WatcherService
@@ -70,6 +71,7 @@ class SessionTracker(private val service: WatcherService) {
7071
nextRemindSec = LimitConfig.load(service).remindMin.toLong() * 60
7172
DebugLog.log("▶ 开始计时「${rule.name}")
7273
service.showTimerOverlay { overlayText() }
74+
if (AppState.isNotifTimer(service)) TimerNotification.show(service, rule.name, 0)
7375
handler.removeCallbacks(ticker)
7476
handler.postDelayed(ticker, 1000L)
7577
}
@@ -83,6 +85,7 @@ class SessionTracker(private val service: WatcherService) {
8385
StatsStore.addSeconds(service, rule.id, sec)
8486
service.hideTimerOverlay()
8587
service.hideIntervention()
88+
TimerNotification.hide(service)
8689
}
8790

8891
private fun sessionSec() = (SystemClock.elapsedRealtime() - sessionStartMs) / 1000
@@ -116,6 +119,11 @@ class SessionTracker(private val service: WatcherService) {
116119
DebugLog.log("🔔 周期提醒:连刷 ${s / 60} 分钟")
117120
service.flashReminder("🌱 这一波已经刷了 ${s / 60} 分钟")
118121
}
122+
123+
// 通知/灵动岛的文字每分钟刷新一次(chronometer 走秒不依赖这里)
124+
if (s > 0 && s % 60 == 0L && AppState.isNotifTimer(service)) {
125+
activeRule?.let { TimerNotification.show(service, it.name, s) }
126+
}
119127
}
120128

121129
private fun overlayText(): String {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.snownamida.touchgrass.track
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.content.Context
7+
import android.graphics.drawable.Icon
8+
import android.os.Bundle
9+
import com.snownamida.touchgrass.R
10+
import org.json.JSONObject
11+
12+
/**
13+
* 会话进行中的常驻通知:
14+
* - 所有机型:通知栏/锁屏显示自走秒的计时(chronometer)
15+
* - HyperOS:附带焦点通知/超级岛参数(miui.focus.param)。官方协议公开但
16+
* 渲染上岛需要小米授予权限,没有权限时系统按普通通知显示——优雅降级。
17+
*/
18+
object TimerNotification {
19+
private const val CHANNEL_ID = "timer"
20+
private const val NOTIF_ID = 42
21+
private var sequence = 0L
22+
23+
fun show(context: Context, ruleName: String, elapsedSec: Long) {
24+
val nm = context.getSystemService(NotificationManager::class.java)
25+
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
26+
nm.createNotificationChannel(
27+
NotificationChannel(CHANNEL_ID, "刷屏计时", NotificationManager.IMPORTANCE_LOW)
28+
)
29+
}
30+
31+
val minutes = elapsedSec / 60
32+
val content = if (minutes > 0) "这一波已刷 $minutes 分钟" else "开始计时…"
33+
34+
val builder = Notification.Builder(context, CHANNEL_ID)
35+
.setSmallIcon(R.drawable.ic_stat_grass)
36+
.setContentTitle("咋还在刷 · $ruleName")
37+
.setContentText(content)
38+
.setWhen(System.currentTimeMillis() - elapsedSec * 1000)
39+
.setUsesChronometer(true)
40+
.setOngoing(true)
41+
.setOnlyAlertOnce(true)
42+
43+
val pics = Bundle()
44+
pics.putParcelable(
45+
"miui.focus.pic_grass",
46+
Icon.createWithResource(context, R.drawable.ic_stat_grass)
47+
)
48+
val extras = Bundle()
49+
extras.putBundle("miui.focus.pics", pics)
50+
builder.addExtras(extras)
51+
52+
val notification = builder.build()
53+
notification.extras.putString("miui.focus.param", islandParam(ruleName, content))
54+
// 用户没给通知权限时 notify 会被系统拒绝,静默即可(悬浮计时药丸不受影响)
55+
runCatching { nm.notify(NOTIF_ID, notification) }
56+
}
57+
58+
fun hide(context: Context) {
59+
runCatching { context.getSystemService(NotificationManager::class.java).cancel(NOTIF_ID) }
60+
}
61+
62+
/** HyperOS 焦点通知/超级岛协议(param_v2),字段参考小米官方与阿里云 EMAS 文档。 */
63+
private fun islandParam(ruleName: String, content: String): String {
64+
sequence += 1
65+
val picInfo = JSONObject().put("type", 1).put("pic", "miui.focus.pic_grass")
66+
val textInfo = JSONObject().put("title", "咋还在刷").put("content", content)
67+
return JSONObject().put(
68+
"param_v2",
69+
JSONObject()
70+
.put("protocol", 1)
71+
.put("business", "timer")
72+
.put("updatable", true)
73+
.put("sequence", sequence)
74+
.put("baseInfo", JSONObject().put("type", 1).put("title", "咋还在刷 · $ruleName").put("content", content))
75+
.put(
76+
"param_island",
77+
JSONObject()
78+
.put("islandProperty", 1)
79+
.put("smallIslandArea", JSONObject().put("picInfo", picInfo))
80+
.put(
81+
"bigIslandArea",
82+
JSONObject().put(
83+
"imageTextInfoLeft",
84+
JSONObject().put("type", 1).put("picInfo", picInfo).put("textInfo", textInfo)
85+
)
86+
)
87+
.put("shareData", JSONObject().put("title", "咋还在刷计时"))
88+
)
89+
).toString()
90+
}
91+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- 状态栏小图标:纯白秒表(状态栏图标只认 alpha 通道) -->
3+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
4+
android:width="24dp"
5+
android:height="24dp"
6+
android:viewportWidth="24"
7+
android:viewportHeight="24">
8+
<path
9+
android:pathData="M5.5,13.5 a6.5,6.5 0 1,0 13,0 a6.5,6.5 0 1,0 -13,0"
10+
android:strokeColor="#FFFFFF"
11+
android:strokeWidth="2"
12+
android:fillColor="#00000000" />
13+
<path
14+
android:pathData="M12,4 L12,7"
15+
android:strokeColor="#FFFFFF"
16+
android:strokeWidth="2"
17+
android:strokeLineCap="round" />
18+
<path
19+
android:pathData="M9.5,3 L14.5,3"
20+
android:strokeColor="#FFFFFF"
21+
android:strokeWidth="2"
22+
android:strokeLineCap="round" />
23+
<path
24+
android:pathData="M12,13.5 L12,10"
25+
android:strokeColor="#FFFFFF"
26+
android:strokeWidth="2"
27+
android:strokeLineCap="round" />
28+
</vector>

app/src/main/res/layout/activity_main.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@
102102
android:layout_marginTop="8dp"
103103
android:textAppearance="?attr/textAppearanceBodySmall"
104104
android:textColor="?android:attr/textColorSecondary" />
105+
106+
<com.google.android.material.materialswitch.MaterialSwitch
107+
android:id="@+id/switch_notif"
108+
android:layout_width="match_parent"
109+
android:layout_height="wrap_content"
110+
android:layout_marginTop="4dp"
111+
android:text="通知栏 / 灵动岛计时(实验性)" />
105112
</LinearLayout>
106113
</com.google.android.material.card.MaterialCardView>
107114

0 commit comments

Comments
 (0)