Skip to content
Merged
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
16 changes: 15 additions & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,23 @@ jobs:
- name: Assemble debug
run: ./gradlew assembleDebug --stacktrace

- name: Unit tests
# This also runs the deterministic eval suite (com.screensaathi.evals.*).
# OFFLINE mode replays recorded responses through the real parser, so it
# needs no API key and costs nothing — safe on every PR. The golden suite
# fails the build on a critical safety or schema regression; the
# adversarial suite reports known gaps without gating. LIVE evals run
# separately in evals-live.yml.
- name: Unit tests + deterministic evals
run: ./gradlew testDebugUnitTest --stacktrace

- name: Upload eval report
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-report
path: eval-results/
if-no-files-found: ignore
Comment on lines +83 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- workflow references ---'
rg -n -C 3 'uses:|zizmor|upload-artifact|checkout|setup-java|setup-gradle|setup-android' .github/workflows/android.yml .github/workflows/evals-live.yml .github 2>/dev/null | head -240
printf '%s\n' '--- repository action-pinning policy ---'
rg -n -C 4 'zizmor|pin|immutable|sha|unpinned' .github .zizmor.yml .zizmor.yaml zizmor.yml zizmor.yaml 2>/dev/null || true
printf '%s\n' '--- relevant workflow sections ---'
sed -n '70,100p' .github/workflows/android.yml
sed -n '15,70p' .github/workflows/evals-live.yml

Repository: NITISH-R-G/ScreenSaathi

Length of output: 15421


Pin the GitHub Actions to immutable commit SHAs.

Replace the mutable tags in the listed android.yml and evals-live.yml steps with full commit SHAs. These actions run in workflows with repository credentials and the LIVE evaluation secret.

🧰 Tools
🪛 zizmor (1.29.0)

[error] 85-85: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

📍 Affects 2 files
  • .github/workflows/android.yml#L83-L89 (this comment)
  • .github/workflows/evals-live.yml#L23-L31
  • .github/workflows/evals-live.yml#L55-L61
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/android.yml around lines 83 - 89, Pin every listed GitHub
Action to an immutable full commit SHA instead of a mutable tag: update the
Upload eval report step in .github/workflows/android.yml (lines 83-89) and both
affected action steps in .github/workflows/evals-live.yml (lines 23-31 and
55-61). Preserve each step’s existing action and configuration while replacing
only the version references.

Source: Linters/SAST tools


- name: Lint
run: ./gradlew lintDebug --stacktrace
continue-on-error: true
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/evals-live.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# LIVE evaluation suite — calls Sarvam for real and therefore costs money.
#
# Deliberately NOT on push/pull_request. The deterministic suite already gates
# every PR for free in android.yml; running paid model calls on every commit
# would cost more than it tells us. This is manual, plus a weekly drift check.
name: Evals (LIVE)

on:
workflow_dispatch:
schedule:
# Weekly, Monday 03:00 UTC — catches model-side drift without a daily bill.
- cron: '0 3 * * 1'

permissions:
contents: read

jobs:
live-evals:
runs-on: ubuntu-latest
timeout-minutes: 20
Comment on lines +8 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- workflow ---'
cat -n .github/workflows/evals-live.yml

printf '%s\n' '--- related workflow references ---'
rg -n --hidden -S 'evals-live|Sarvam|sarvam|concurrency:|workflow_dispatch:|schedule:' .github README.md 2>/dev/null || true

printf '%s\n' '--- workflow files ---'
git ls-files '.github/workflows/*'

Repository: NITISH-R-G/ScreenSaathi

Length of output: 4684


🏁 Script executed:

#!/bin/bash
set -eu

cat -n .github/workflows/evals-live.yml
rg -n --hidden -S 'evals-live|Sarvam|sarvam|concurrency:|workflow_dispatch:|schedule:' .github README.md 2>/dev/null || true
git ls-files '.github/workflows/*'

Repository: NITISH-R-G/ScreenSaathi

Length of output: 4608


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- existing concurrency configuration ---'
cat -n .github/workflows/android.yml | sed -n '1,25p'

printf '%s\n' '--- LIVE evaluation sources and tests ---'
rg -n --hidden -S 'SCREENSAATHI_EVAL_LIVE|SARVAM_API_KEY|eval-results|class .*Eval|`@Test`|Sarvam' app .github 2>/dev/null | head -250

printf '%s\n' '--- read-only workflow invariant check ---'
python3 - <<'PY'
from pathlib import Path

p = Path(".github/workflows/evals-live.yml")
text = p.read_text()

checks = {
    "manual dispatch trigger": "  workflow_dispatch:" in text,
    "weekly schedule trigger": "  schedule:" in text and "cron: '0 3 * * 1'" in text,
    "LIVE mode enabled": "SCREENSAATHI_EVAL_LIVE: '1'" in text,
    "Sarvam key passed": "SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }}" in text,
    "no workflow concurrency key": "\nconcurrency:" not in text,
}
for name, result in checks.items():
    print(f"{name}: {result}")
PY

Repository: NITISH-R-G/ScreenSaathi

Length of output: 24525


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- existing concurrency configuration ---'
cat -n .github/workflows/android.yml | sed -n '1,25p'

printf '%s\n' '--- LIVE evaluation sources and tests ---'
rg -n --hidden -S 'SCREENSAATHI_EVAL_LIVE|SARVAM_API_KEY|eval-results|class .*Eval|`@Test`|Sarvam' app .github 2>/dev/null | head -250

printf '%s\n' '--- read-only workflow invariant check ---'
python3 - <<'PY'
from pathlib import Path

text = Path(".github/workflows/evals-live.yml").read_text()
checks = {
    "manual dispatch trigger": "  workflow_dispatch:" in text,
    "weekly schedule trigger": "  schedule:" in text and "cron: '0 3 * * 1'" in text,
    "LIVE mode enabled": "SCREENSAATHI_EVAL_LIVE: '1'" in text,
    "Sarvam key passed": "SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }}" in text,
    "no workflow concurrency key": "\nconcurrency:" not in text,
}
for name, result in checks.items():
    print(f"{name}: {result}")
PY

Repository: NITISH-R-G/ScreenSaathi

Length of output: 24525


🌐 Web query:

GitHub Actions workflow concurrency group cancel-in-progress false scheduled workflow manual dispatch official documentation

💡 Result:

When using a concurrency group in GitHub Actions with cancel-in-progress: false (the default behavior), GitHub Actions ensures that only one workflow or job in that group is actively running at a time [1][2][3]. If a new workflow run or job is triggered while another in the same concurrency group is already running: 1. The new (queued) run will be set to pending status [1][2]. 2. It will wait until the currently running job completes [3]. 3. Any previously existing pending run in that same group will be canceled, ensuring there is at most one running and one pending job per group [1][2][4]. For workflows involving multiple trigger types—such as schedule (cron) and workflow_dispatch (manual)—sharing a concurrency group can cause unexpected behavior where one trigger type cancels or forces another into a pending state [5][6]. To prevent scheduled or manual jobs from being unexpectedly superseded by other triggers, you can use dynamic concurrency group keys that incorporate the event type [5][6]. For example: concurrency: group: ${{ github.workflow }}-${{ github.event_name }} cancel-in-progress: false This configuration isolates the concurrency group by event, allowing manual dispatches, scheduled runs, and push events to run in their own respective "buckets," so they do not compete with or cancel each other [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- LIVE test branches and request execution ---'
for f in \
  app/src/test/java/com/screensaathi/evals/PlannerEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/LatencyProfileTest.kt \
  app/src/test/java/com/screensaathi/evals/OpenEndedEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/DeviceAwarenessEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/EvalHarness.kt
do
  echo "--- $f ---"
  rg -n -C 5 'val live|SCREENSAATHI_EVAL_LIVE|newCall|execute\(|planOpenEnded|plan\(' "$f"
done

printf '%s\n' '--- read-only LIVE call-path summary ---'
python3 - <<'PY'
from pathlib import Path
import re

files = sorted(Path("app/src/test/java/com/screensaathi/evals").glob("*EvalTest.kt"))
for path in files:
    text = path.read_text()
    has_live = "SCREENSAATHI_EVAL_LIVE" in text
    calls = len(re.findall(r'\.newCall\(', text))
    executes = len(re.findall(r'\.execute\(', text))
    print(f"{path}: live_guard={has_live}, newCall={calls}, execute={executes}")
PY

Repository: NITISH-R-G/ScreenSaathi

Length of output: 8101


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- LIVE test branches and request execution ---'
for f in \
  app/src/test/java/com/screensaathi/evals/PlannerEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/LatencyProfileTest.kt \
  app/src/test/java/com/screensaathi/evals/OpenEndedEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/DeviceAwarenessEvalTest.kt \
  app/src/test/java/com/screensaathi/evals/EvalHarness.kt
do
  echo "--- $f ---"
  rg -n -C 5 'val live|SCREENSAATHI_EVAL_LIVE|newCall|execute\(|planOpenEnded|plan\(' "$f"
done

printf '%s\n' '--- read-only LIVE call-path summary ---'
python3 - <<'PY'
from pathlib import Path
import re

for path in sorted(Path("app/src/test/java/com/screensaathi/evals").glob("*EvalTest.kt")):
    text = path.read_text()
    print(
        f"{path}: "
        f"live_guard={'SCREENSAATHI_EVAL_LIVE' in text}, "
        f"newCall={len(re.findall(r'\\.newCall\\(', text))}, "
        f"execute={len(re.findall(r'\\.execute\\(', text))}"
    )
PY

Repository: NITISH-R-G/ScreenSaathi

Length of output: 7897


Serialize LIVE evaluation runs.

The manual and weekly triggers can overlap. LIVE mode makes paid Sarvam requests, so overlap can duplicate requests and increase rate-limit pressure. Add a workflow-level concurrency group with cancel-in-progress: false.

🧰 Tools
🪛 zizmor (1.29.0)

[info] 18-18: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)


[warning] 8-12: insufficient job-level concurrency limits (concurrency-limits): workflow is missing concurrency setting

(concurrency-limits)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/evals-live.yml around lines 8 - 20, Add workflow-level
concurrency configuration for the live-evals workflow, using a stable group and
setting cancel-in-progress to false so manual and scheduled runs queue rather
than overlap or cancel one another. Place it alongside the top-level permissions
and jobs configuration.

Source: Linters/SAST tools


steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'

- uses: gradle/actions/setup-gradle@v4
- uses: android-actions/setup-android@v3

- name: Install SDK packages
run: sdkmanager --install "platforms;android-36.1" "build-tools;36.1.0"

# Skips rather than fails when the secret is absent, so a fork or a repo
# without the key does not show a permanently red scheduled job.
- name: Check for key
id: key
run: |
if [ -z "${{ secrets.SARVAM_API_KEY }}" ]; then
echo "::warning::SARVAM_API_KEY not set — skipping LIVE evals."
echo "present=false" >> "$GITHUB_OUTPUT"
else
echo "present=true" >> "$GITHUB_OUTPUT"
fi

- name: Run LIVE evals
if: steps.key.outputs.present == 'true'
env:
SCREENSAATHI_EVAL_LIVE: '1'
SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }}
run: ./gradlew :app:testDebugUnitTest --tests "com.screensaathi.evals.*" --rerun-tasks --stacktrace

- name: Upload LIVE eval report
if: always() && steps.key.outputs.present == 'true'
uses: actions/upload-artifact@v4
with:
name: eval-report-live
path: eval-results/
if-no-files-found: ignore
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# local.properties holds the Sarvam API key and the SDK path. It must NEVER be
# committed — the key reaches the app through BuildConfig at build time instead.
local.properties
*.keystore
*.jks

# Build output
build/
Expand All @@ -20,3 +22,7 @@ scripts/*.wav
.idea/
*.iml
.DS_Store

# Generated eval reports. The committed artefact is the baseline in
# evals/baselines/, not each run's output.
eval-results/
11 changes: 11 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ android {
// The classes under test call android.util.Log. Without this every
// Log line throws "not mocked" and the test fails for no reason.
isReturnDefaultValues = true

all {
// The eval suite prints its scorecard and each failing case to
// stdout. Without this Gradle swallows it and a red build says
// nothing about WHICH case regressed.
it.testLogging {
showStandardStreams = true
events("passed", "failed", "skipped")
}
}
}
}
}
Expand All @@ -65,6 +75,7 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.okhttp)

testImplementation(libs.junit)
Expand Down
75 changes: 63 additions & 12 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,31 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<!-- Package visibility (Android 11+): without these, getLaunchIntentForPackage
returns null for every one of them and the app reports that no taxi app
is installed on a phone that plainly has three. Named explicitly rather
than QUERY_ALL_PACKAGES — we only ever offer this list. Keep in sync
with RideApps.KNOWN. -->
<!--
Universal app discovery (Android 11+ package visibility).

This replaced a seven-package allow-list, which made the assistant
structurally incapable of its own core function: any app outside that list
was invisible, so "open WhatsApp" could only ever fail, and DeviceContext
correctly but uselessly reported UNKNOWN for nearly everything installed.

QUERY_ALL_PACKAGES is a Play-policy restricted permission. The
justification here is that app discovery IS the user-facing feature — the
user names an app and the assistant must determine whether it exists — and
a Play submission would have to declare exactly that. Nothing leaves the
device: the inventory is read on-device and only a compact, capped list of
labels ever reaches the model (see DeviceContext.toPromptText).
-->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />

<queries>
<package android:name="com.ubercab" />
<package android:name="com.olacabs.customer" />
<package android:name="com.rapido.passenger" />
<package android:name="in.indrive.client" />
<package android:name="com.blusmart.rider" />
<package android:name="app.nammayatri.passenger" />
<package android:name="com.meru.mgt" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>

<application

Check warning on line 35 in app/src/main/AndroidManifest.xml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"usesCleartextTraffic" is implicitly enabled for older Android versions. Make sure allowing clear-text traffic is safe here.

See more on https://sonarcloud.io/project/issues?id=NITISH-R-G_ScreenSaathi&issues=AZ_vuIKvlvhjkM8dql7J&open=AZ_vuIKvlvhjkM8dql7J&pullRequest=12
android:name=".SaathiApp"
android:allowBackup="false"
android:label="@string/app_name"
Expand All @@ -42,6 +51,33 @@
</intent-filter>
</activity>

<!--
ScreenSaathi as the home screen.

Opt-in by construction: declaring CATEGORY_HOME only makes the app
*eligible*; Android routes HOME here solely if the user selects
ScreenSaathi as their default launcher. A user who never does is
unaffected, and MainActivity remains the normal entry point.

stateUnchanged/adjustPan and the excludeFromRecents/singleTask combo
are the standard launcher posture — a home screen should not stack
instances or appear in Recents as if it were an ordinary app.
-->
<activity
android:name=".launcher.LauncherActivity"
android:exported="true"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:stateNotNeeded="true"
android:windowSoftInputMode="stateUnchanged|adjustPan"
android:theme="@style/Theme.ScreenSaathi">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<!-- The controlled screen the assistant guides the user through. Real
Android views with real resource ids, read live over accessibility. -->
<activity
Expand All @@ -50,6 +86,21 @@
android:label="@string/demo_title"
android:windowSoftInputMode="adjustResize" />

<!--
Trigger a highlight without changing the foreground app. Exported so
it can be driven from adb during development and demos; it carries no
data and only starts our own already-running service.
-->
<receiver

Check failure on line 94 in app/src/main/AndroidManifest.xml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that intents are received safely here.

See more on https://sonarcloud.io/project/issues?id=NITISH-R-G_ScreenSaathi&issues=AZ_vuIKvlvhjkM8dql7I&open=AZ_vuIKvlvhjkM8dql7I&pullRequest=12
android:name=".HighlightReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.screensaathi.HIGHLIGHT" />
<action android:name="com.screensaathi.CLEAR_HIGHLIGHT" />
<action android:name="com.screensaathi.DUMP_SCREEN" />
</intent-filter>
</receiver>
Comment on lines +94 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict the debug broadcast receiver.

android:exported="true" lets other applications invoke these actions. An untrusted application can trigger highlight, clear, and dump commands. Android recommends access control for intentionally exported receivers. (developer.android.com)

Make this receiver non-exported in release builds. Put adb-only controls in the debug manifest, or require a signature-level permission.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/AndroidManifest.xml` around lines 94 - 102, Restrict the
HighlightReceiver from untrusted applications by making it non-exported in
release builds. Move the adb-only actions and receiver declaration to the debug
manifest, or protect the exported receiver with a signature-level permission
while preserving authorized debug access.


<service
android:name=".OverlayService"
android:exported="false"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/assets/tasks/pay_bill.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"hi-IN": "बिल भरने के लिए यह बटन दबाइए।"
},
"expects_value": false,
"irreversible": true,
"highlight": { "shape": "rect", "pulse": true }
}
]
Expand Down
63 changes: 46 additions & 17 deletions app/src/main/java/com/screensaathi/AppLauncher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,56 @@ import android.content.Context
import android.content.Intent

object AppLauncher {

/**
* Resolves an app name to a package and launches it natively.
* Resolves a spoken app name to a package name, without launching it.
*
* Split from [launchApp] so the caller can pin the resolved package (for
* subsequent screen matching) and run it past the safety guard BEFORE
* anything starts.
*
* Exact label match wins over a substring one: "Uber" must not resolve to
* "Uber Eats" just because that happened to be enumerated first.
*/
fun launchApp(context: Context, appName: String): Boolean {
if (appName.isBlank()) return false
fun resolvePackageName(context: Context, appName: String): String? {
if (appName.isBlank()) return null
val pm = context.packageManager
val mainIntent = Intent(Intent.ACTION_MAIN, null)
mainIntent.addCategory(Intent.CATEGORY_LAUNCHER)

val apps = pm.queryIntentActivities(mainIntent, 0)
for (info in apps) {
val label = info.loadLabel(pm).toString()
if (label.equals(appName, ignoreCase = true) || label.contains(appName, ignoreCase = true)) {
val intent = pm.getLaunchIntentForPackage(info.activityInfo.packageName)
if (intent != null) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return true
}
}
.addCategory(Intent.CATEGORY_LAUNCHER)

val apps = try {
pm.queryIntentActivities(mainIntent, 0)
} catch (e: Exception) {
return null
}

val labelled = apps.mapNotNull { info ->
val pkg = info.activityInfo?.packageName ?: return@mapNotNull null
val label = runCatching { info.loadLabel(pm).toString() }.getOrNull() ?: return@mapNotNull null
label to pkg
}

labelled.firstOrNull { (label, _) -> label.equals(appName, ignoreCase = true) }
?.let { return it.second }

return labelled.firstOrNull { (label, _) -> label.contains(appName, ignoreCase = true) }?.second
}

/**
* Brings the named app to the front. Returns false when it could not be
* resolved or started — the caller must not report success on false.
*/
fun launchApp(context: Context, appName: String): Boolean {
val pkg = resolvePackageName(context, appName) ?: return false
val intent = context.packageManager.getLaunchIntentForPackage(pkg) ?: return false
// Resume an existing task rather than starting a cold copy: the user may
// already be signed in and part-way through.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
return try {
context.startActivity(intent)
true
} catch (e: Exception) {
false
}
return false
}
}
63 changes: 63 additions & 0 deletions app/src/main/java/com/screensaathi/HighlightReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.screensaathi

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log

/**
* Triggers a highlight without disturbing whatever is on screen.
*
* An Activity-based trigger cannot work for this: starting an Activity brings
* it to the foreground, so by the time the resolver reads the accessibility
* tree the target app is no longer in front and it resolves against the wrong
* screen. A broadcast changes nothing about the foreground task.
*
* adb shell am broadcast -a com.screensaathi.HIGHLIGHT --es query "Wi-Fi"
*
* This is the same resolver and overlay the voice path uses — only speech
* recognition is skipped — so exercising it is a real test of the pipeline.
*/
class HighlightReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_HIGHLIGHT -> {
val query = intent.getStringExtra("query").orEmpty()
if (query.isBlank()) return
Log.d(TAG, "HIGHLIGHT_REQUEST query='$query'")
OverlayService.highlight(context, query)
}
// get_current_screen(): dump the live ScreenModel exactly as the
// resolver sees it. This is the tool the agent will call, and it is
// also the only honest way to tell "the node is missing from our
// snapshot" apart from "the node is there but scored too low".
ACTION_DUMP -> {
val snap = ScreenReaderService.instance?.snapshot()
if (snap == null) {
Log.w(TAG, "SCREEN_DUMP unavailable: accessibility service not bound")
return
}
Log.w(TAG, "SCREEN_DUMP pkg=${snap.packageName} settled=${snap.settled} " +
"elements=${snap.elements.size}")
val filter = intent.getStringExtra("filter")?.lowercase().orEmpty()
snap.elements.forEachIndexed { i, e ->
val line = "[$i] id='${e.resourceId}' text='${e.text}' cls=${e.className} " +
"click=${e.clickable} edit=${e.editable} bounds=${e.bounds.toShortString()}"
if (filter.isEmpty() || line.lowercase().contains(filter)) Log.w(TAG, " $line")
}
}
Comment on lines +35 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'AndroidManifest.xml' | xargs -r rg -n -C6 'HighlightReceiver|DUMP_SCREEN|com.screensaathi.HIGHLIGHT|exported'

Repository: NITISH-R-G/ScreenSaathi

Length of output: 3171


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- HighlightReceiver.kt ---'
cat -n app/src/main/java/com/screensaathi/HighlightReceiver.kt
printf '%s\n' '--- manifest receiver context ---'
sed -n '80,108p' app/src/main/AndroidManifest.xml
printf '%s\n' '--- receiver call sites and action definitions ---'
rg -n -C3 'HighlightReceiver|ACTION_DUMP|DUMP_SCREEN|HIGHLIGHT|CLEAR_HIGHLIGHT|SCREEN_DUMP' app/src
printf '%s\n' '--- build configuration ---'
rg -n -C3 'buildTypes|BuildConfig|applicationId|namespace' app/build.gradle app/build.gradle.kts build.gradle build.gradle.kts settings.gradle settings.gradle.kts 2>/dev/null || true

Repository: NITISH-R-G/ScreenSaathi

Length of output: 15543


🏁 Script executed:

#!/bin/bash
python3 - <<'PY'
from pathlib import Path
import re
import xml.etree.ElementTree as ET

receiver = Path("app/src/main/java/com/screensaathi/HighlightReceiver.kt").read_text()
manifest = ET.parse("app/src/main/AndroidManifest.xml").getroot()
ns = {"a": "http://schemas.android.com/apk/res/android"}

branches = re.findall(r'\b(ACTION_[A-Z]+)\s*->\s*\{', receiver)
dump_block = receiver.split("ACTION_DUMP ->", 1)[1].split("ACTION_CLEAR ->", 1)[0]
logs_foreground_fields = all(
    field in dump_block for field in ("snap.packageName", "e.resourceId", "e.text", "e.bounds")
)
debug_guarded = "BuildConfig.DEBUG" in dump_block

receiver_nodes = [
    node for node in manifest.findall(".//receiver")
    if node.get("{http://schemas.android.com/apk/res/android}name") == ".HighlightReceiver"
]
exported = receiver_nodes[0].get("{http://schemas.android.com/apk/res/android}exported") if receiver_nodes else None
actions = [
    action.get("{http://schemas.android.com/apk/res/android}name")
    for action in receiver_nodes[0].findall("./intent-filter/action", ns)
] if receiver_nodes else []

print({
    "branches": branches,
    "dump_logs_foreground_fields": logs_foreground_fields,
    "dump_has_debug_guard": debug_guarded,
    "receiver_exported": exported,
    "receiver_actions": actions,
})
PY

Repository: NITISH-R-G/ScreenSaathi

Length of output: 444


Restrict HighlightReceiver to debug or protected use.

The exported receiver logs foreground accessibility content at WARN, including text, resource IDs, and bounds. Guard ACTION_DUMP with BuildConfig.DEBUG. Make the receiver non-exported or protect it with a signature-level permission; otherwise any app can invoke DUMP_SCREEN or HIGHLIGHT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/screensaathi/HighlightReceiver.kt` around lines 35 -
49, The ACTION_DUMP handling in HighlightReceiver must only run in debug builds
by guarding it with BuildConfig.DEBUG before logging snapshot content. Restrict
the receiver itself from arbitrary external apps by making it non-exported or
requiring a signature-level permission, while preserving intended internal
DUMP_SCREEN and HIGHLIGHT behavior.

ACTION_CLEAR -> {
Log.d(TAG, "HIGHLIGHT_CLEAR")
OverlayService.clearHighlight(context)
}
}
}

companion object {
const val ACTION_HIGHLIGHT = "com.screensaathi.HIGHLIGHT"
const val ACTION_CLEAR = "com.screensaathi.CLEAR_HIGHLIGHT"
const val ACTION_DUMP = "com.screensaathi.DUMP_SCREEN"
private const val TAG = "HighlightReceiver"
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/screensaathi/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ class MainActivity : AppCompatActivity() {
OverlayService.runTask(this, task, language)
}
"choose" -> OverlayService.choose(this, intent.getIntExtra("choice", 0))
// adb: am start -n com.screensaathi/.MainActivity --es saathi_action highlight --es query "Wi-Fi"
"highlight" -> {
val q = intent.getStringExtra("query").orEmpty()
if (q.isNotBlank()) OverlayService.highlight(this, q)
}
}
intent.removeExtra(EXTRA_ACTION)
finish()
Expand Down
Loading