Skip to content

Commit 512ef1b

Browse files
committed
fix: deep audit — thread safety, correctness, UX, and design system hardening
Thread safety: ArrayStore uses ConcurrentHashMap; VariableStore local scopes use ConcurrentHashMap; WiFi/Connectivity monitors mark lastState volatile; CameraMicContextEvents callback fields marked volatile. Resource leak: AutomationService.onDestroy now calls CameraMicContextEvents.stop() to unregister AppOps watchers. Data corruption: HTTP readBounded collects into ByteArrayOutputStream before UTF-8 decode to prevent multi-byte character corruption at read boundaries. Correctness: BrightnessAction auto mode sets SCREEN_BRIGHTNESS_MODE instead of writing -1; ScreenTimeoutAction clamps to 30 min max; SunEventCalculator uses event-time offset instead of noon for DST accuracy; battery_level/charging seeded from sticky broadcast at service start; FlowGraphCard uses firstOrNull to prevent NoSuchElementException; TTS SayAction guards double-resume with AtomicBoolean. Safety: vibration capped at 10s; queued task depth capped at 50; WAL checkpoint uses TRUNCATE for safer backups; PendingIntent request codes use hash to prevent overflow. UX: disabledAlpha uses Modifier.alpha instead of black overlay (fixes light theme); warning color uses amber/peach instead of green; nav icons have contentDescription; card radius uses DesignSystem.Radii.xxl token.
1 parent 4aca8e7 commit 512ef1b

23 files changed

Lines changed: 92 additions & 50 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenTasker
22

3-
[![Version](https://img.shields.io/badge/version-0.2.66-blue.svg)](https://github.com/SysAdminDoc/OpenTasker/releases)
3+
[![Version](https://img.shields.io/badge/version-0.2.67-blue.svg)](https://github.com/SysAdminDoc/OpenTasker/releases)
44
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
55
[![Platform](https://img.shields.io/badge/platform-Android%208.0%2B-brightgreen.svg)](https://developer.android.com)
66
[![Kotlin](https://img.shields.io/badge/kotlin-2.3.21-7f52ff.svg)](https://kotlinlang.org)
@@ -20,7 +20,7 @@
2020

2121
**Planned:** broad device-verified background geofence reliability, elevated (Shizuku) execution, Termux script dispatch, a visual flow authoring editor, and richer plugin UX. See [ROADMAP.md](ROADMAP.md).
2222

23-
> **Status:** the current source version is `0.2.66`. Device-evidence claims (location/calendar/sun) are single-device API 36 data points on `SM-S938B`, not broad background-geofence reliability guarantees. The latest polish pass improves IME handling, compact small-screen bottom navigation with a More menu, squared-off navigation/FAB affordances, variable and scene-element deletion safety, saveable editor/context/template/scene-element state, scene-element nudge controls for non-drag movement, numeric form keyboards, larger day-token touch targets, and accessibility roles for widget/flow/form switch targets.
23+
> **Status:** the current source version is `0.2.67`. Device-evidence claims (location/calendar/sun) are single-device API 36 data points on `SM-S938B`, not broad background-geofence reliability guarantees. The latest polish pass improves IME handling, compact small-screen bottom navigation with a More menu, squared-off navigation/FAB affordances, variable and scene-element deletion safety, saveable editor/context/template/scene-element state, scene-element nudge controls for non-drag movement, numeric form keyboards, larger day-token touch targets, and accessibility roles for widget/flow/form switch targets.
2424
2525
---
2626

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ val releaseKeystorePath = System.getenv("OPEN_TASKER_RELEASE_KEYSTORE")
1010
val releaseKeystorePassword = System.getenv("OPEN_TASKER_RELEASE_KEYSTORE_PASSWORD")
1111
val releaseKeyAlias = System.getenv("OPEN_TASKER_RELEASE_KEY_ALIAS")
1212
val releaseKeyPassword = System.getenv("OPEN_TASKER_RELEASE_KEY_PASSWORD")
13-
val appVersionCode = 68
14-
val appVersionName = "0.2.66"
13+
val appVersionCode = 69
14+
val appVersionName = "0.2.67"
1515
val allowedDistributions = setOf("standard", "fdroid", "play")
1616
val selectedDistribution = providers.gradleProperty("openTaskerDistribution")
1717
.orElse("standard")

app/src/main/java/com/opentasker/automation/network/ConnectivityMonitor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class ConnectivityMonitor(context: Context) {
1414
private val appContext = context.applicationContext
1515
private val cm = appContext.getSystemService(ConnectivityManager::class.java)
1616
private val started = AtomicBoolean(false)
17-
private var lastState: ConnState? = null
17+
@Volatile private var lastState: ConnState? = null
1818

1919
private val callback = object : ConnectivityManager.NetworkCallback() {
2020
override fun onAvailable(network: Network) {

app/src/main/java/com/opentasker/automation/network/WiFiNetworkMonitor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class WiFiNetworkMonitor(
1818
private val appContext = context.applicationContext
1919
private val connectivityManager = appContext.getSystemService(ConnectivityManager::class.java)
2020
private val started = AtomicBoolean(false)
21-
private var lastState: WiFiState? = null
21+
@Volatile private var lastState: WiFiState? = null
2222

2323
private val callback = object : ConnectivityManager.NetworkCallback() {
2424
override fun onAvailable(network: Network) {

app/src/main/java/com/opentasker/automation/sensor/ShakeDetector.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import kotlin.math.sqrt
1111

1212
class ShakeDetector(context: Context) {
1313

14-
private val sensorManager = context.getSystemService(SensorManager::class.java)
14+
private val sensorManager = context.applicationContext.getSystemService(SensorManager::class.java)
1515
private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
1616

1717
private var lastShakeTime = 0L

app/src/main/java/com/opentasker/core/actions/BuiltInActions.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,12 @@ class NotifyAction : Action {
7070
action = NotificationActionReceiver.ACTION_NOTIFICATION_BUTTON
7171
putExtra(NotificationActionReceiver.EXTRA_TASK_NAME, taskName)
7272
putExtra(NotificationActionReceiver.EXTRA_BUTTON_LABEL, label)
73-
putExtra("_req", notifId * 10 + i)
73+
putExtra("_req", (notifId.hashCode() * 31 + i) and 0x7FFFFFFF)
7474
}
75+
val requestCode = (notifId.hashCode() * 31 + i) and 0x7FFFFFFF
7576
val pi = PendingIntent.getBroadcast(
7677
ctx.app,
77-
notifId * 10 + i,
78+
requestCode,
7879
buttonIntent,
7980
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
8081
)
@@ -192,24 +193,24 @@ class SayAction : Action {
192193
}
193194
return suspendCancellableCoroutine { cont ->
194195
var tts: android.speech.tts.TextToSpeech? = null
196+
val resumed = java.util.concurrent.atomic.AtomicBoolean(false)
197+
fun completeOnce(result: ActionResult) {
198+
if (resumed.compareAndSet(false, true)) {
199+
tts?.shutdown()
200+
cont.resumeWith(Result.success(result))
201+
}
202+
}
195203
tts = android.speech.tts.TextToSpeech(ctx.app) { status ->
196204
if (status != android.speech.tts.TextToSpeech.SUCCESS) {
197-
tts?.shutdown()
198-
cont.resumeWith(Result.success(ActionResult.Failure("TTS engine initialization failed (status=$status)")))
205+
completeOnce(ActionResult.Failure("TTS engine initialization failed (status=$status)"))
199206
return@TextToSpeech
200207
}
201208
val engine = tts ?: return@TextToSpeech
202209
engine.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
203210
override fun onStart(utteranceId: String?) {}
204-
override fun onDone(utteranceId: String?) {
205-
engine.shutdown()
206-
cont.resumeWith(Result.success(ActionResult.Success))
207-
}
211+
override fun onDone(utteranceId: String?) { completeOnce(ActionResult.Success) }
208212
@Deprecated("Deprecated in API 21+")
209-
override fun onError(utteranceId: String?) {
210-
engine.shutdown()
211-
cont.resumeWith(Result.success(ActionResult.Failure("TTS utterance failed")))
212-
}
213+
override fun onError(utteranceId: String?) { completeOnce(ActionResult.Failure("TTS utterance failed")) }
213214
})
214215
ctx.logger("TTS: ${text.take(80)}${if (text.length > 80) "..." else ""}")
215216
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "opentasker_say")

app/src/main/java/com/opentasker/core/actions/NetworkActions.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,16 +252,16 @@ private const val MAX_DOWNLOAD_BYTES = 52_428_800L // 50 MB for file downloads
252252

253253
private fun InputStream.readBounded(maxBytes: Long): String {
254254
val buffer = ByteArray(8192)
255-
val result = StringBuilder()
255+
val result = java.io.ByteArrayOutputStream()
256256
var total = 0L
257257
while (true) {
258258
val n = read(buffer)
259259
if (n < 0) break
260260
total += n
261261
if (total > maxBytes) throw IllegalStateException("response exceeds ${maxBytes / 1024 / 1024} MB limit")
262-
result.append(String(buffer, 0, n, Charsets.UTF_8))
262+
result.write(buffer, 0, n)
263263
}
264-
return result.toString()
264+
return result.toByteArray().toString(Charsets.UTF_8)
265265
}
266266

267267
private fun InputStream.copyBounded(out: java.io.OutputStream, maxBytes: Long): Long {

app/src/main/java/com/opentasker/core/actions/SettingsActions.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,14 @@ class BrightnessAction : Action {
100100
return ActionResult.Failure("Write system settings permission is not granted")
101101
}
102102
return try {
103-
val value = when (brightness.lowercase()) {
104-
"auto" -> -1
105-
else -> brightness.toInt().coerceIn(0, 255)
103+
val resolver = ctx.app.contentResolver
104+
if (brightness.lowercase() == "auto") {
105+
Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC)
106+
} else {
107+
val value = brightness.toInt().coerceIn(0, 255)
108+
Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL)
109+
Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS, value)
106110
}
107-
Settings.System.putInt(ctx.app.contentResolver, Settings.System.SCREEN_BRIGHTNESS, value)
108111
ctx.logger("Brightness: $brightness")
109112
ActionResult.Success
110113
} catch (e: Exception) {
@@ -318,7 +321,7 @@ class ScreenTimeoutAction : Action {
318321
override val category = ActionCategory.SETTINGS
319322

320323
override suspend fun run(ctx: ActionContext, args: Map<String, String>): ActionResult {
321-
val ms = args["millis"]?.toLongOrNull() ?: 30000L
324+
val ms = (args["millis"]?.toLongOrNull() ?: 30000L).coerceIn(0L, MAX_SCREEN_TIMEOUT_MS)
322325
if (!Settings.System.canWrite(ctx.app)) {
323326
return ActionResult.Failure("Write system settings permission is not granted")
324327
}
@@ -330,4 +333,8 @@ class ScreenTimeoutAction : Action {
330333
ActionResult.Failure("failed: ${e.message}")
331334
}
332335
}
336+
337+
companion object {
338+
private const val MAX_SCREEN_TIMEOUT_MS = 1_800_000L // 30 minutes
339+
}
333340
}

app/src/main/java/com/opentasker/core/actions/SystemActions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class VibrateAction : Action {
2121
override val category = ActionCategory.NOTIFICATION
2222

2323
override suspend fun run(ctx: ActionContext, args: Map<String, String>): ActionResult {
24-
val millis = args["millis"]?.toLongOrNull() ?: 100L
24+
val millis = (args["millis"]?.toLongOrNull() ?: 100L).coerceIn(1L, 10_000L)
2525
return try {
2626
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
2727
ctx.app.getSystemService(Context.VIBRATOR_MANAGER_SERVICE)?.let {

app/src/main/java/com/opentasker/core/contexts/CameraMicContextEvents.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ object CameraMicContextEvents {
1111
private val events = MutableSharedFlow<ContextEvent>(extraBufferCapacity = 16)
1212
val flow: SharedFlow<ContextEvent> = events.asSharedFlow()
1313

14-
private var cameraCallback: AppOpsManager.OnOpActiveChangedListener? = null
15-
private var micCallback: AppOpsManager.OnOpActiveChangedListener? = null
14+
@Volatile private var cameraCallback: AppOpsManager.OnOpActiveChangedListener? = null
15+
@Volatile private var micCallback: AppOpsManager.OnOpActiveChangedListener? = null
1616

1717
fun start(context: Context) {
1818
if (Build.VERSION.SDK_INT < 29) return

0 commit comments

Comments
 (0)