Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 112 additions & 193 deletions packages/addon/src/db/ALL.json

Large diffs are not rendered by default.

18 changes: 17 additions & 1 deletion packages/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ pnpm lint
pnpm clean
```

## Debugging on Device

ADB helpers live in `scripts/`:

- [`scripts/adb.sh`](scripts/adb.sh) — install / uninstall / clear-data / generic logcat (`bash scripts/adb.sh install`).
- [`scripts/trigger-worker.sh`](scripts/trigger-worker.sh) — exercise the background `ScanWorker` end-to-end. Triggers one-shot scans, force-runs the periodic JobScheduler job, simulates `PACKAGE_ADDED` broadcasts, and tails the relevant log tags. See [`scripts/trigger-worker.md`](scripts/trigger-worker.md) for the full command reference.

```bash
bash scripts/trigger-worker.sh scan # one-shot scan
bash scripts/trigger-worker.sh force-now # re-run periodic job, ignore constraints
bash scripts/trigger-worker.sh logs # tail ScanWorker / receivers / WorkManager
```

## Release

### First-Time Setup
Expand Down Expand Up @@ -235,7 +248,10 @@ scripts/
├── build.sh # Build with JAVA_HOME setup
├── release.sh # Full release workflow
├── bump-version.sh # Version incrementing
└── validate-metadata.sh # Metadata validation
├── validate-metadata.sh # Metadata validation
├── adb.sh # ADB helpers (install/uninstall/logcat)
├── trigger-worker.sh # ScanWorker testing harness (see trigger-worker.md)
└── trigger-worker.md # Worker-trigger command reference

fastlane/
└── metadata/android/ # Play Store metadata
Expand Down
154 changes: 154 additions & 0 deletions packages/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import com.android.build.api.variant.AndroidComponentsExtension
import groovy.json.JsonSlurper
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties

Expand Down Expand Up @@ -158,3 +170,145 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

// ============================================================================
// Package visibility — generates a <queries> manifest from ALL.json so we can
// drop the QUERY_ALL_PACKAGES permission (Play Store policy compliance).
//
// Output: app/build/generated/queries/<variant>/AndroidManifest.xml
// Wired into manifest merge via androidComponents.onVariants below.
//
// Source-of-truth is the schema field `android_curated_app_ids` on each
// blocklist entry in ALL.json (defined in @theWallProject/common). Any entry
// that has `android_dev_id` but neither `android_app_ids` nor
// `android_curated_app_ids` will fail the build — Android <queries> only
// matches exact package names, never prefixes, so an orphaned dev-id silently
// makes that company's apps invisible to the on-device scanner.
//
// LIMITATION — curation completeness is NOT verified.
// The orphan check below only catches entries that declare a dev_id with
// zero concrete app IDs (a 100% blind entry). It cannot detect *incomplete*
// curation: e.g. android_dev_id="com.wix" with curated=["com.wix.admin"] is
// accepted, but if a real user has "com.wix.somenewapp" installed, the OS
// will not surface it because <queries> has no prefix syntax. Pre-<queries>
// the runtime prefix matcher (AppScanner.matchesPackage / ScanWorker)
// would have caught it under QUERY_ALL_PACKAGES; under <queries> it cannot.
//
// Mitigation: periodically audit curated_app_ids against the Play Store
// listing for each developer namespace and update @theWallProject/scrapper
// manual_resolve/manualOverrides.ts. There is no automated guard for this.
// ============================================================================

@CacheableTask
abstract class GenerateQueriesManifestTask : DefaultTask() {

@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val allJsonFile: RegularFileProperty

@get:OutputFile
abstract val outputManifest: RegularFileProperty

@TaskAction
fun generate() {
val json = JsonSlurper().parse(allJsonFile.get().asFile) as List<*>

val packages = sortedSetOf<String>()
val orphanedDevIds = mutableListOf<String>()

for (entry in json) {
val item = entry as? Map<*, *> ?: continue

val explicitAppIds = (item["android_app_ids"] as? List<*>)
?.mapNotNull { (it as? String)?.takeIf { s -> s.isValidPackage() } }
?: emptyList()
packages.addAll(explicitAppIds)

val curatedAppIds = (item["android_curated_app_ids"] as? List<*>)
?.mapNotNull { (it as? String)?.takeIf { s -> s.isValidPackage() } }
?: emptyList()
packages.addAll(curatedAppIds)

(item["hint_android_id"] as? String)?.takeIf { it.isValidPackage() }?.let(packages::add)

val devId = item["android_dev_id"] as? String
if (devId != null) {
// The dev-id itself only matches if there's a literal package
// with that exact name (rare, but harmless to declare).
if (devId.isValidPackage()) {
packages.add(devId)
}
// Hard fail rule: if a company declares android_dev_id as the
// only Android signal, the scanner can't see any of their apps
// unless we enumerate them. This blocks silent regressions.
if (explicitAppIds.isEmpty() && curatedAppIds.isEmpty()) {
val name = item["n"] as? String ?: item["id"] as? String ?: "<unknown>"
orphanedDevIds.add("$name (dev_id=$devId)")
}
}
}

if (orphanedDevIds.isNotEmpty()) {
val message = buildString {
append("Orphaned android_dev_id entries detected in ALL.json — these companies ")
append("declare a developer prefix but no concrete app IDs, so the scanner ")
append("cannot detect their apps under <queries>:\n\n")
orphanedDevIds.sorted().forEach { append(" • ").append(it).append('\n') }
append("\nFix: add `android_curated_app_ids: [...]` (or `android_app_ids: [...]`) ")
append("to each entry in @theWallProject/scrapper, then run the scrapper to ")
append("regenerate ALL.json. See packages/common/src/index.ts for schema.")
}
throw GradleException(message)
}

val xml = buildString {
append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n")
append("<!-- AUTO-GENERATED by :app:")
append(name)
append(" — DO NOT EDIT. Source: ALL.json -->\n")
append("<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n")
append(" <queries>\n")
for (pkg in packages) {
append(" <package android:name=\"")
append(pkg)
append("\" />\n")
}
append(" </queries>\n")
append("</manifest>\n")
}

val outFile = outputManifest.get().asFile
outFile.parentFile.mkdirs()
outFile.writeText(xml)
logger.lifecycle("Generated <queries> manifest with ${packages.size} packages → $outFile")
}

private fun String.isValidPackage(): Boolean {
// Android package names: at least one dot, lowercase letters/digits/underscores
// per segment, segments must start with a letter.
if (!contains('.')) return false
return split('.').all { seg ->
seg.isNotEmpty() && seg[0].isLetter() && seg.all { c -> c.isLetterOrDigit() || c == '_' }
}
}
}

androidComponents {
onVariants { variant ->
val variantName = variant.name
val capitalizedName = variantName.replaceFirstChar { it.uppercase() }
val taskProvider = tasks.register<GenerateQueriesManifestTask>(
"generate${capitalizedName}QueriesManifest"
) {
allJsonFile.set(layout.projectDirectory.file("src/main/assets/ALL.json"))
outputManifest.set(
layout.buildDirectory.file("generated/queries/$variantName/AndroidManifest.xml")
)
}

variant.sources.manifests.addGeneratedManifestFile(
taskProvider = taskProvider,
wiredWith = GenerateQueriesManifestTask::outputManifest
)
}
}
27 changes: 27 additions & 0 deletions packages/android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only manifest overlay. Merged on top of app/src/main/AndroidManifest.xml
for debug builds and stripped entirely from release. Hosts ADB-only test
hooks that must not ship to production.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>

<!--
ADB trigger for the ScanWorker. Lets `adb shell am broadcast` enqueue
a one-shot scan with forceNotify=true, without launching MainActivity.

See: scripts/trigger-worker.sh, app/src/debug/.../ScanTriggerReceiver.kt
-->
<receiver
android:name=".background.ScanTriggerReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.thewallboycott.android.action.TRIGGER_SCAN" />
</intent-filter>
</receiver>

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.thewallboycott.android.background

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager

/**
* Debug-only entry point that lets ADB enqueue a one-shot ScanWorker without
* launching MainActivity. Registered via app/src/debug/AndroidManifest.xml so
* it is stripped entirely from release builds.
*
* Trigger:
* adb shell am broadcast \
* -a com.thewallboycott.android.action.TRIGGER_SCAN \
* -p com.thewallboycott.android \
* --ez force_notify true
*
* The `force_notify=true` extra bypasses the dedup/recently-notified guard in
* ScanWorker so the notification fires every time, which is what manual
* testing wants.
*/
class ScanTriggerReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_TRIGGER_SCAN) {
Log.d(TAG, "Ignoring non-trigger action: ${intent.action}")
return
}

val forceNotify = intent.getBooleanExtra(EXTRA_FORCE_NOTIFY, true)
Log.d(TAG, "Trigger received. forceNotify=$forceNotify — enqueuing ScanWorker")

val scanRequest = OneTimeWorkRequestBuilder<ScanWorker>()
.setInputData(
Data.Builder()
.putBoolean(ScanWorker.INPUT_FORCE_NOTIFY, forceNotify)
.build()
)
.build()

WorkManager.getInstance(context).enqueueUniqueWork(
UNIQUE_WORK_NAME,
ExistingWorkPolicy.REPLACE,
scanRequest
)
}

companion object {
private const val TAG = "ScanTriggerReceiver"
const val ACTION_TRIGGER_SCAN = "com.thewallboycott.android.action.TRIGGER_SCAN"
const val EXTRA_FORCE_NOTIFY = "force_notify"
private const val UNIQUE_WORK_NAME = "adb_trigger_scan"
}
}
10 changes: 6 additions & 4 deletions packages/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Allows scanning all apps -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<!--
Package visibility is declared via a <queries> block generated at build
time from ALL.json (see app/build.gradle.kts :: GenerateQueriesManifestTask).
We deliberately do NOT request QUERY_ALL_PACKAGES — Play Store policy
restricts it to a narrow set of app categories.
-->

<!-- Allows uninstalling apps -->
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
Expand Down
Loading