Skip to content

Commit 25ac9ed

Browse files
johannesjoclaude
andauthored
ci: typecheck the extension and run the Android unit tests (#282)
* ci: typecheck the extension and run the Android unit tests `tsc --noEmit` never ran in CI (#152) and did not pass: the ambient asset declarations in global.d.ts sat in a file that a top-level `import type` had turned into a module, so every scss/svg/mp3 import failed to resolve, and without skipLibCheck third-party typings failed too. That noise hid a handful of real errors: a timer id typed as NodeJS.Timeout in a browser file, a manifest override read off an untyped JSON import, and the background message listener's implicitly-any parameters. - keep global.d.ts a script (import() types instead of a top-level import) - tsconfig: skipLibCheck, resolveJsonModule, explicit include - fix the four real errors; the listener now narrows its message - add `npm run typecheck` and a CI step for it - run the Android JVM unit test suite as its own CI job (#151) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * fix(android): every overlay that covers an app opens on the sky (#118, #196) The fresh intervention already opens on the clock-matched loading sky with the native sun blooming on it; what was left of #118 were the two sibling overlays. The sleep wind-down window still fell back to the flat dark ground, faded in over the blocked app for 300ms, forced a hardware layer on its WebView (the very thing that makes an overlay WebView skip its first composite), and had none of the first-frame nudges or the failed- WebView teardown. The success sun window never opted out of system-bar insets, so its gradient could leave an uncovered strip under the gesture bar. - CommonWindow: the initial shield is painted before addView through an overridable hook; the first-frame hardening (alpha nudge, invalidate pump) and the failed-WebView teardown move here so every WebView overlay shares one copy instead of re-deriving it. - LoadingSky: the sky backdrop composable and the local-hour helper are shared rather than private to the intervention window. - SleepWindDownOverlayWindow: instantly-opaque loading-sky shield, transparent WebView over it, no forced hardware layer, the shared hardening, full-screen insets. - SuccessSunWindow: layout under the system bars and the cutout like its siblings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * feat(android): say when minded can't see your apps Detection lives inside the accessibility service and dies with it. The permission checks catch the service being switched off, but not the failure that actually happens in the field: the toggle still shows on after an OEM kill or an update, nothing is bound, no intervention ever fires, and a local-only app has no other way to tell the user it has gone quiet. - the service keeps a connected flag across onServiceConnected / onUnbind / onDestroy - the main bridge exposes it with the settings state as DetectionHealth (JSON shape covered by a JVM test) - the Android shell reads it on every refresh and shows one calm line when enabled-but-unbound; the tap opens the accessibility settings, where switching the service off and on re-binds it. A missing or malformed read counts as watching, so the line can only ever appear on a known fact - never on event silence, which an idle phone produces too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * feat(settings): save a copy of your answers, and bring one back All data is local and every platform is an island: the extension's sync storage is small and prunes the oldest answers when it fills, a lost phone takes its answers with it, and nothing moves between the extension and the app. The answer journal is the most personal thing minded holds, so it gets a way out that doesn't depend on us - a plain JSON file the user keeps, restorable on any platform. - journalBackup.ts: the file format (answers + custom questions), tolerant parsing, and an additive merge by id that never overwrites an existing entry and is idempotent; unit-tested - fileTransfer.ts: Blob download on the extension; on Android the bridge opens the system "save as" sheet (ACTION_CREATE_DOCUMENT), and a plain <input type="file"> is routed to the document picker through WebChromeClient.onShowFileChooser - one "Your answers" section on both settings pages: Save a copy / Bring a copy back, with a single result line Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * refactor(interaction): pull pure logic out of the two orchestrators InteractionCommon.tsx and Sun.tsx are the files every audit flags as too large, and both kept growing anyway - each polish pass landed inside them. This moves the pieces that don't need the component into tested modules next to them, and adds a ratchet test that pins both files at their new size so a change can't quietly add to either. - interactionCommonHelpers.ts: the widget-line match and the two DOM probes that were module-level in InteractionCommon - interactionCornerSettle.ts: the Little Sun corner settle (arriving / departing, per platform, honouring Android's dragged rest centre) and the platform-sized phase settle - sun/sunGeometry.ts: anchor point for a settle, rest scale, and the rendered-translate parse - sun/sunGlow.ts: the rest/hover glow constants with their reasoning, the glow-axis temperature read, and the pointer-feedback scale - the interaction's cleanup lists its frames and timeouts instead of eleven copies of the same if-block Behaviour is unchanged; the extracted functions are covered by unit tests, and the full suite, typecheck, and production build pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * fix: address review findings on the journal, detection line, and overlays From an adversarial and a normal review of the branch: - journal import on the extension: answers live in one browser sync item of a few kilobytes, so a phone's journal was rejected wholesale. The import now brings back the most recent imported answers that fit, never touching the device's own, and says so plainly; a non-quota failure gets a neutral message instead of a guessed cause. The quota check moves to a platform-neutral module so the app bundles don't pull in the extension's storage code. - journal entries are rebuilt from known fields only; oversized picks are refused before parsing - Android "save a copy": the export is staged in a cache file, so a process reclaimed behind the system picker can no longer leave the chosen document empty; a second tap while the sheet is up is ignored - detection line: sits above the optional-permissions invite (which says "minded already works"), and the state is re-read shortly after mount so a cold-start bind race can't leave a stale line standing - CommonWindow owns the loading-sky shield behind one flag instead of two copies in the intervention and wind-down windows - Play internal and release builds run the Android unit suite before bundleRelease (#151) - copy: drop the "stays on this device" claim (untrue on the extension); the size ratchet gets a few lines of headroom; README lists the new checks and no longer names a landing-page directory that isn't here Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc * fix(android): restore MainActivity's companion and route helpers The review-fix edit that staged the export in a cache file replaced the whole span between saveTextFile and onCreate, taking the companion object (widget intent extras, fade constants) and the intent-route helpers with it. CI's first Kotlin compile of the branch caught it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vfDHpx2YT8CkxPPATgigc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6fd872 commit 25ac9ed

52 files changed

Lines changed: 2088 additions & 478 deletions

Some content is hidden

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

.github/workflows/build.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ jobs:
4242
- name: Lint
4343
run: npm run lint
4444

45+
- name: Typecheck
46+
run: npm run typecheck
47+
4548
- name: Test Unit
4649
run: npm test
4750

@@ -54,3 +57,33 @@ jobs:
5457
with:
5558
name: minded-download
5659
path: extension/minded.zip
60+
61+
android-unit-tests:
62+
# The Android host has its own JVM test suite (decision engine, detection
63+
# confidence, widget copy rules, sync-data serialization). It never ran in
64+
# CI before, so a regression there only surfaced on a device. Pure JVM
65+
# tests, so no emulator and no web bundle are needed (assets/ only holds
66+
# a .gitkeep; the WebView bundle is a build-time artifact).
67+
runs-on: ubuntu-latest
68+
timeout-minutes: 20
69+
defaults:
70+
run:
71+
working-directory: ./android
72+
steps:
73+
- name: Check out Git repository
74+
uses: actions/checkout@v7.0.1
75+
with:
76+
persist-credentials: false
77+
- uses: actions/setup-java@v5
78+
with:
79+
distribution: temurin
80+
java-version: '17'
81+
cache: gradle
82+
- name: Android unit tests
83+
run: ./gradlew :app:testDebugUnitTest --console=plain
84+
- name: Upload test reports
85+
if: failure()
86+
uses: actions/upload-artifact@v7
87+
with:
88+
name: android-unit-test-reports
89+
path: android/app/build/reports/tests/

.github/workflows/play-internal.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ jobs:
8787
# Timestamp codes are opaque; record code -> commit so a versionCode
8888
# seen in the Play Console can be traced back to a SHA.
8989
echo "internal versionCode \`$ANDROID_VERSION_CODE\` ← \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY"
90+
# The JVM unit suite gates the artifact: a red decision-engine or
91+
# widget-copy test must never ship (#151).
92+
./gradlew :app:testDebugUnitTest --console=plain
9093
./gradlew bundleRelease
9194
- name: Upload AAB as workflow artifact
9295
if: always()

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ jobs:
111111
# Timestamp codes are opaque; record code -> tag/commit so a
112112
# versionCode seen in the Play Console can be traced back.
113113
echo "production versionCode \`$ANDROID_VERSION_CODE\` ← \`$GITHUB_REF_NAME\` (\`$GITHUB_SHA\`)" >> "$GITHUB_STEP_SUMMARY"
114+
# The JVM unit suite gates the artifact: a red decision-engine or
115+
# widget-copy test must never ship (#151).
116+
./gradlew :app:testDebugUnitTest --console=plain
114117
./gradlew bundleRelease
115118
- name: Upload AAB as workflow artifact
116119
if: always()

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,10 @@ npm run buildDroid # production android assets
102102

103103
```bash
104104
cd extension
105-
npm test # jest
106-
npm run lint # eslint --fix
105+
npm test # jest
106+
npm run typecheck # tsc --noEmit (CI runs this too)
107+
npm run lint # eslint --fix
108+
npm run test:android # the Android host's JVM unit tests (needs the Android SDK)
107109
```
108110

109111
Run a single test: `npx jest path/to/test.spec.ts`.
@@ -113,11 +115,12 @@ Run a single test: `npx jest path/to/test.spec.ts`.
113115
```
114116
extension/ Browser-extension build (Vite + CRXJS) and shared SolidJS UI
115117
android/ Native Android host (Kotlin) wrapping the shared web UI
116-
landing-page/ Marketing site (Astro) at minded.today
117118
common/ Shared assets (logos, icons)
118119
docs/ Architecture and design notes
119120
```
120121

122+
The marketing site lives in its own repository.
123+
121124
The web UI under `extension/src/shared/` is reused across all platforms. Platform-specific data access goes through the **dataInterface pattern** documented in [`CLAUDE.md`](./CLAUDE.md) - that file is the best entry point for understanding the architecture.
122125

123126
> **iOS note:** iOS ships as a deliberately minimal **widget-only variant** - the companion sun as a Home Screen widget (`extension/ios/App/MindedWidget/`), presence + invitation only. It does *not* attempt the forced intervention/overlay, which is the wrong shape for iOS (see [`docs/ios-platform-fit.md`](./docs/ios-platform-fit.md)). New *intervention* features still target the browser extension and Android. iOS builds and TestFlight ship from CI with no local Mac - see [`RELEASING.md`](./RELEASING.md).

android/app/src/main/java/com/minded/minded/MainActivity.kt

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.minded.minded
22

3+
import com.minded.minded.detection.DetectionHealth
34
import com.minded.minded.data.SharedPreferenceService
45
import android.annotation.SuppressLint
56
import android.content.Intent
@@ -10,6 +11,7 @@ import android.provider.Settings
1011
import android.util.Log
1112
import android.view.ViewGroup
1213
import android.webkit.ValueCallback
14+
import android.webkit.WebChromeClient
1315
import android.webkit.WebResourceRequest
1416
import android.webkit.WebSettings
1517
import android.webkit.WebView
@@ -18,6 +20,7 @@ import androidx.activity.OnBackPressedCallback
1820
import androidx.activity.SystemBarStyle
1921
import androidx.activity.compose.setContent
2022
import androidx.activity.enableEdgeToEdge
23+
import androidx.activity.result.contract.ActivityResultContracts
2124
import androidx.appcompat.app.AppCompatActivity
2225
import androidx.compose.foundation.layout.Box
2326
import androidx.compose.foundation.layout.fillMaxSize
@@ -41,6 +44,7 @@ import com.minded.minded.util.isAccessibilityServiceEnabled
4144
import com.minded.minded.util.isDarkModeNow
4245
import com.minded.minded.widget.WidgetPrompts
4346
import kotlinx.coroutines.launch
47+
import java.io.File
4448

4549

4650
enum class MissingCapability {
@@ -59,6 +63,63 @@ class MainActivity : AppCompatActivity() {
5963
private val logTag = "MainActivity"
6064
private val baseUrl = "file:///android_asset/web/src/android/main/index.html"
6165

66+
// Answer-journal backup, native half (see extension util/fileTransfer.ts).
67+
// "Save a copy": the web layer's text is parked in a cache file while the
68+
// system "save as" sheet (another process) is in front, then copied to the
69+
// document the user chose. A *file*, not a field: if this process is
70+
// reclaimed behind the picker the activity is recreated and the result still
71+
// arrives - a field would be null by then and the picker-created document
72+
// would be left empty, the worst outcome for a backup. Writes go through
73+
// "wt" so a re-chosen existing document is truncated, never appended to.
74+
private val pendingSaveTextFile: File
75+
get() = File(cacheDir, "journal-export.json")
76+
private val createDocumentLauncher = registerForActivityResult(
77+
ActivityResultContracts.CreateDocument("application/json"),
78+
) { uri ->
79+
isSaveTextFilePending = false
80+
val source = pendingSaveTextFile
81+
try {
82+
if (uri == null || !source.exists()) return@registerForActivityResult
83+
contentResolver.openOutputStream(uri, "wt")?.use { out ->
84+
source.inputStream().use { it.copyTo(out) }
85+
} ?: Log.e(logTag, "saveTextFile: provider gave no stream for $uri")
86+
} catch (e: Exception) {
87+
Log.e(logTag, "saveTextFile: writing the document failed", e)
88+
} finally {
89+
source.delete()
90+
}
91+
}
92+
private var isSaveTextFilePending = false
93+
94+
// "Bring a copy back": a plain <input type="file"> in the WebView lands in
95+
// WebChromeClient.onShowFileChooser, which opens the system document picker
96+
// and hands the chosen URI back to the WebView - so the web side reads it
97+
// like on any browser.
98+
private var pendingFileChooser: ValueCallback<Array<Uri>>? = null
99+
private val openDocumentLauncher = registerForActivityResult(
100+
ActivityResultContracts.OpenDocument(),
101+
) { uri ->
102+
pendingFileChooser?.onReceiveValue(if (uri == null) null else arrayOf(uri))
103+
pendingFileChooser = null
104+
}
105+
106+
private fun saveTextFile(filename: String, content: String) {
107+
// JS-bridge calls arrive off the main thread; launchers want it.
108+
runOnUiThread {
109+
// A second tap while the sheet is up would stack a second picker
110+
// whose cancel wipes the first one's payload; ignore it instead.
111+
if (isSaveTextFilePending) return@runOnUiThread
112+
try {
113+
pendingSaveTextFile.writeText(content, Charsets.UTF_8)
114+
} catch (e: Exception) {
115+
Log.e(logTag, "saveTextFile: staging the export failed", e)
116+
return@runOnUiThread
117+
}
118+
isSaveTextFilePending = true
119+
createDocumentLauncher.launch(filename)
120+
}
121+
}
122+
62123
companion object {
63124
/** Intent extra naming a hash route to open on launch (allow-listed below). */
64125
const val EXTRA_LAUNCH_ROUTE = "launch_route"
@@ -229,9 +290,37 @@ class MainActivity : AppCompatActivity() {
229290
this,
230291
::onMissingCapabilityTap,
231292
::getMissingCapabilities,
293+
getDetectionHealthI = ::getDetectionHealth,
294+
onSaveTextFileI = ::saveTextFile,
232295
safeAreaInsets = safeAreaInsetsHolder,
233296
)
234297
addJavascriptInterface(jsInterface, jsInterfaceNameProp)
298+
// Note: installing any WebChromeClient also
299+
// makes the WebView show JS alert()/confirm()
300+
// as native dialogs (without one Chromium
301+
// drops them silently). The only alert the web
302+
// layer raises here is the honest save-failure
303+
// notice (handleDataError, alertUser) - which
304+
// should be seen, so this is accepted.
305+
webChromeClient = object : WebChromeClient() {
306+
override fun onShowFileChooser(
307+
webView: WebView?,
308+
filePathCallback: ValueCallback<Array<Uri>>?,
309+
fileChooserParams: FileChooserParams?,
310+
): Boolean {
311+
if (filePathCallback == null) return false
312+
// A chooser that never returned must be
313+
// released before a new one is armed.
314+
pendingFileChooser?.onReceiveValue(null)
315+
pendingFileChooser = filePathCallback
316+
// Any type: the web side validates the
317+
// file's content, and a narrow MIME filter
318+
// hides json files some providers label
319+
// as octet-stream.
320+
openDocumentLauncher.launch(arrayOf("*/*"))
321+
return true
322+
}
323+
}
235324
// Stay invisible until the first real frame commits; the
236325
// half-initialised transparent surface would otherwise
237326
// show as the cold-start teal/orange compositing stripes.
@@ -451,6 +540,11 @@ class MainActivity : AppCompatActivity() {
451540
}
452541
}
453542

543+
private fun getDetectionHealth(): DetectionHealth = DetectionHealth(
544+
accessibilityEnabled = isAccessibilityServiceEnabled(this),
545+
serviceConnected = MyAccessibilityService.isConnected,
546+
)
547+
454548
private fun getMissingCapabilities(): List<MissingCapability> {
455549
Log.v(
456550
logTag,

android/app/src/main/java/com/minded/minded/MainActivityJavaScriptInterface.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.view.inputmethod.InputMethodManager
1111
import android.webkit.JavascriptInterface
1212
import android.webkit.WebView
1313
import com.minded.minded.MissingCapability
14+
import com.minded.minded.detection.DetectionHealth
1415
import com.minded.minded.util.SafeAreaInsetsHolder
1516
import com.minded.minded.util.getAppUsageObservation
1617
import com.minded.minded.widget.MyAppWidgetReceiver
@@ -22,6 +23,11 @@ open class MainActivityJavaScriptInterface(
2223
protected open val webView: WebView,
2324
protected val onMissingCapabilityClickI: (MissingCapability) -> Unit = {},
2425
protected val getMissingCapabilitiesI: () -> List<MissingCapability> = { emptyList<MissingCapability>() },
26+
protected val getDetectionHealthI: () -> DetectionHealth = {
27+
DetectionHealth(accessibilityEnabled = false, serviceConnected = false)
28+
},
29+
/** Opens the system "save as" sheet for a text file (see [saveTextFile]). */
30+
protected val onSaveTextFileI: (filename: String, content: String) -> Unit = { _, _ -> },
2531
/**
2632
* Latest system-bar + display-cutout insets, written by
2733
* [com.minded.minded.util.ForwardSafeAreaInsetsToWebView] and read by
@@ -149,6 +155,26 @@ open class MainActivityJavaScriptInterface(
149155
return jsonArray.toString()
150156
}
151157

158+
/**
159+
* The answer-journal backup: the web layer hands over a file name and its
160+
* text, the activity opens ACTION_CREATE_DOCUMENT so the user picks where it
161+
* goes. The name is reduced to a safe character set here - it names a
162+
* document, never a path.
163+
*/
164+
@JavascriptInterface
165+
fun saveTextFile(filename: String, content: String) {
166+
Log.v(logTag, "saveTextFile() $filename (${content.length} chars)")
167+
val safeName = filename.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "minded.json" }
168+
onSaveTextFileI(safeName, content)
169+
}
170+
171+
/** See [DetectionHealth]: lets the dashboard say when minded can't see apps. */
172+
@JavascriptInterface
173+
fun getDetectionHealth(): String {
174+
Log.v(logTag, "getDetectionHealth()")
175+
return getDetectionHealthI().toJson()
176+
}
177+
152178
@JavascriptInterface
153179
fun getAllApps(): String {
154180
val allApps = getInstalledApps()

android/app/src/main/java/com/minded/minded/MyAccessibilityService.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ class MyAccessibilityService : AccessibilityService() {
104104
private var detectionCollectionJob: kotlinx.coroutines.Job? = null
105105

106106
companion object {
107+
/**
108+
* True between onServiceConnected() and onUnbind()/onDestroy(): the
109+
* system currently holds a live binding to this service, so detection
110+
* is running. Read by the main activity for the dashboard's detection-
111+
* health line (see detection/DetectionHealth.kt) - the one signal that
112+
* tells "enabled in settings" apart from "actually running".
113+
*/
114+
@Volatile
115+
var isConnected: Boolean = false
116+
private set
117+
107118
const val INTENT_EXTRA_CURRENT_PACKAGE_NAME = "INTENT_EXTRA_CURRENT_PACKAGE_NAME"
108119
const val INTENT_EXTRA_DETECTION_TIMESTAMP = "INTENT_EXTRA_DETECTION_TIMESTAMP"
109120
const val INTENT_EXTRA_HIDE_OVERLAY = "INTENT_EXTRA_HIDE_OVERLAY"
@@ -313,11 +324,13 @@ class MyAccessibilityService : AccessibilityService() {
313324

314325
override fun onUnbind(intent: Intent?): Boolean {
315326
Log.d(TAG, "onUnbind()")
327+
isConnected = false
316328
return super.onUnbind(intent)
317329
}
318330

319331
override fun onDestroy() {
320332
super.onDestroy()
333+
isConnected = false
321334

322335
// Stop detection collection job first
323336
detectionCollectionJob?.cancel()
@@ -358,6 +371,7 @@ class MyAccessibilityService : AccessibilityService() {
358371
override fun onServiceConnected() {
359372
Log.d(TAG, "onServiceConnected()")
360373
super.onServiceConnected()
374+
isConnected = true
361375

362376
// Configure the service programmatically to ensure it works reliably
363377
val config = AccessibilityServiceInfo().apply {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.minded.minded.detection
2+
3+
import org.json.JSONObject
4+
5+
/**
6+
* Whether minded can currently see which app is in front - the fact the
7+
* dashboard needs in order to say, calmly, when it can't.
8+
*
9+
* Detection lives inside the accessibility service and dies with it (see
10+
* docs/sun-escalation-and-detection-reliability.md, "Accessibility optional").
11+
* The failure mode this exists for is the one the permission checks miss: the
12+
* service is still *enabled* in system settings, so no capability is reported
13+
* missing, but its binding is dead (an OEM battery manager killed it, or the
14+
* well-known post-update state where the toggle shows on but nothing runs
15+
* until it is switched off and on again). Then no intervention ever fires and
16+
* nothing tells the user - a local-only app has no other way to learn it has
17+
* gone quiet.
18+
*
19+
* Deliberately NOT based on "no accessibility events for N minutes": a phone
20+
* sitting idle produces no events either, and a wrong "minded isn't seeing
21+
* your apps" would break the 90%-sure bar for anything we say to the user.
22+
* A live binding is a fact; silence is not.
23+
*/
24+
data class DetectionHealth(
25+
/** The accessibility service is enabled in system settings. */
26+
val accessibilityEnabled: Boolean,
27+
/** The service is currently bound and connected (between onServiceConnected and onUnbind/onDestroy). */
28+
val serviceConnected: Boolean,
29+
) {
30+
fun toJson(): String = JSONObject()
31+
.put("accessibilityEnabled", accessibilityEnabled)
32+
.put("serviceConnected", serviceConnected)
33+
.toString()
34+
}

0 commit comments

Comments
 (0)