After 4 rejections from Google Play, I deleted 387 lines of working Kotlin code
and a service my app supposedly needed.
The app got approved 2 days later.
This is the story of how my "essential" feature turned out to be optional, why
Google Play's AccessibilityService policy is brutal but fair, and what I learned
about scope creep on solo projects.
The app
I'm a solo founder. I built Soccialstopper, a screen time app for Android
with one design rule: treat the user like an adult, not a kid being punished.
No hard locks. No red "YOU'VE EXCEEDED YOUR LIMIT" warnings. Just a gentle
floating bubble when you hit your daily limit, and a "Calm Days" garden that
pauses (instead of resetting to zero) when you slip.
Built with Flutter, Kotlin for native Android services.
The detection problem
A screen time app needs to know which app the user has open. On Android, there
are two ways to do this:
| Approach | Latency | Permission | Privacy |
|---|---|---|---|
UsageStatsManager (polling) |
~800ms |
PACKAGE_USAGE_STATS — simple toggle |
Low concern |
AccessibilityService (events) |
<100ms |
BIND_ACCESSIBILITY_SERVICE — sensitive |
High concern |
I implemented both. UsageStatsManager as the primary path,
AccessibilityService as an "advanced" option for users who wanted real-time
detection.
It worked. Locally. Then I submitted to Play Store.
The rejections
Issue found: Missing prominent disclosure
We were unable to approve your app because we could not locate prominent
disclosure of your use of the AccessibilityService API in your app.
Google's AccessibilityService policy is strict for apps that don't directly
help people with disabilities (IsAccessibilityTool). You need:
- Prominent in-app disclosure before requesting the permission
- A demo video uploaded with each submission
- Listed usage in the Play Store description
- Justification that no other API can achieve the same result
I tried compliance: added a disclosure screen, updated my listing copy. Got
rejected again. And again.
The audit that changed everything
Before trying compliance for the 4th time, I did a real audit of how
AccessibilityService was actually used in my codebase. Two questions:
- What does it do that UsageStatsManager doesn't?
- What user-visible features break if I remove it?
Result: my AccessibilityService class was 387 lines of Kotlin that duplicated
work the UsageStatsMonitorService was already doing. The Dart-side helpers
were literal stubs:
static Future<void> startAccessibilityService(List<String> apps) async {
if (kDebugMode) debugPrint('[BlockLogicService] Starting...');
// This method is now handled by updating shared preferences.
// The accessibility service reads from shared preferences.
}
static Future<void> stopAccessibilityService() async {
if (kDebugMode) debugPrint('[BlockLogicService] Stopping requested');
}
That's it. Empty stubs with debugPrint. The "feature" was already dead code
that I'd never noticed because the app worked fine without it.
The only real difference: 700ms of detection latency. For a digital
wellness app, that's imperceptible to the user.
What I removed
// AndroidManifest.xml
- <service
- android:name=".SocialStopperAccessibilityService"
- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
- android:exported="true">
- <intent-filter>
- <action android:name="android.accessibilityservice.AccessibilityService" />
- </intent-filter>
- <meta-data
- android:name="android.accessibilityservice"
- android:resource="@xml/accessibility_service_config" />
- </service>
- Deleted
SocialStopperAccessibilityService.kt(387 lines) - Deleted
accessibility_service_config.xml - Removed 2 method channels from
MainActivity.kt - Removed 2 stub methods from
BlockLogicService.dart - Removed an entire "advanced" view from the setup wizard
- Removed 8 localization keys in 3 languages
- Bumped versionCode
Total: ~600 lines of code gone. Zero user-visible functionality lost.
Verifying the AAB
Before resubmitting, I verified the compiled AAB really had no trace of the
permission:
unzip -q app-release.aab "base/manifest/AndroidManifest.xml" -d /tmp/aab
cat /tmp/aab/base/manifest/AndroidManifest.xml | \
tr -d '\0' | \
grep -aoiE 'BIND_ACCESSIBILITY|accessibilityservice'
Empty output. The AAB only declared the permissions I actually needed:
INTERNET, PACKAGE_USAGE_STATS, POST_NOTIFICATIONS, FOREGROUND_SERVICE,
SYSTEM_ALERT_WINDOW, and a few others.
The Play Console trap
Even with the clean AAB uploaded, the rejection kept coming. Why?
The Accessibility Services declaration I'd filled out earlier in App Content
was still active. Play Console detects the permission in any active artifact
across any track. I had old AABs with the permission still active in:
- Closed Testing Alpha → v20 active (with AccessibilityService)
- Internal Testing → v18 active (with AccessibilityService)
The clean v22 AAB was uploaded but inactive because I hadn't created
releases in those tracks.
The fix: create a release in EVERY track using the clean AAB. Once no active
release had the permission, the declaration in App Content became removable,
and the rejection cycle finally broke.
What I learned
1. Scope creep on solo projects is sneaky.
Six months ago, when I added AccessibilityService, it felt like the "right"
technical choice. Real-time detection is objectively better than polling.
But "better" doesn't always mean "necessary."
The 700ms difference was a feature for me as a developer. To users, it was
invisible. I built it for myself, not for them.
2. Google's strictness on AccessibilityService is actually fair.
The API is dangerous. An accessibility service can read everything on screen.
Google's policy forces you to ask: do you really need this, or is there a
narrower API that works?
For most "monitor what's happening" use cases, the narrower API is
UsageStatsManager. Use it.
3. Play Console state is sticky.
Cleaning your code isn't enough. You also have to clean up old artifacts in
all tracks, and remove answered policy declarations. Those forms persist
based on what's active, not what's latest.
4. Sometimes the right fix is "delete code", not "add code".
I spent a week trying to write a compliant disclosure flow. The actual fix
took 2 hours: delete the service, delete its references, ship.
The code
If you're building something similar, the boring UsageStatsManager polling
approach is enough for 99% of screen time / digital wellness use cases. The
800ms latency feels real-time in practice.
Here's the core pattern:
fun getCurrentForegroundApp(): String? {
val end = System.currentTimeMillis()
val begin = end - 10_000 // 10 second window
val events = usageStatsManager.queryEvents(begin, end)
val event = UsageEvents.Event()
var lastForegroundPackage: String? = null
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
lastForegroundPackage = event.packageName
}
}
return lastForegroundPackage
}
Poll this every ~800ms, compare against the user's app list, accumulate time,
trigger your notification when the limit is hit. No accessibility, no
disclosure forms, no rejections.
The app
If you're curious or want to try it:
Soccialstopper on Google Play
Built with Flutter, free, no signup.
Happy to answer any questions about the build, the Play Store policy
navigation, or the Flutter + Kotlin native services setup.
Top comments (0)