diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e94aa22..10be8b7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -44,6 +44,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
"/requests"},
+ %{"/" => "/requests/*"},
+ %{"/" => "/activities"},
+ %{"/" => "/activities/*"},
+ %{"/" => "/notifications"},
+ %{"/" => "/reports"},
+ %{"/" => "/users/log-in"},
+ %{"/" => "/safety"},
+ %{"/" => "/profile"},
+ %{"/" => "/people/*"},
+ %{"/" => "/leaderboard"},
+ %{"/" => "/categories/proposals"},
+ %{"/" => "*", "exclude" => true}
+ ]
+
def show(conn, _params) do
case Application.get_env(:who_need_help, :android_app_links) do
%{
@@ -16,6 +35,11 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksController do
namespace: "android_app",
package_name: package_name,
sha256_cert_fingerprints: fingerprints
+ },
+ relation_extensions: %{
+ "delegate_permission/common.handle_all_urls" => %{
+ dynamic_app_link_components: @dynamic_app_link_components
+ }
}
}
])
diff --git a/scripts/android-app-links-verify.sh b/scripts/android-app-links-verify.sh
index 36d0d8c..bfa41b9 100755
--- a/scripts/android-app-links-verify.sh
+++ b/scripts/android-app-links-verify.sh
@@ -106,6 +106,21 @@ if [[ "$online_mode" == --online ]]; then
(.target.sha256_cert_fingerprints | index($fingerprint)) != null
)
' <<<"$payload" >/dev/null
+
+ jq -e '
+ any(
+ .[];
+ (
+ .relation_extensions["delegate_permission/common.handle_all_urls"]
+ .dynamic_app_link_components
+ ) as $rules
+ | ($rules | type == "array" and length > 0)
+ and any($rules[]; .["/"] == "/safety" and (.exclude // false) == false)
+ and any($rules[]; .["/"] == "/requests/*" and (.exclude // false) == false)
+ and any($rules[]; .["/"] == "*" and .exclude == true)
+ and all($rules[]; ((.["/"] // "") | startswith("/auth")) | not)
+ )
+ ' <<<"$payload" >/dev/null
fi
echo "Android package, signing certificate, and App Links configuration agree."
diff --git a/scripts/android-play-policy-check.sh b/scripts/android-play-policy-check.sh
index 46149d4..f48b799 100755
--- a/scripts/android-play-policy-check.sh
+++ b/scripts/android-play-policy-check.sh
@@ -44,6 +44,23 @@ require_literal \
'android:foregroundServiceType="location"' \
'TrackingService is not declared as a location foreground service'
+for route in \
+ 'android:path="/requests"' \
+ 'android:pathPrefix="/requests/"' \
+ 'android:path="/activities"' \
+ 'android:pathPrefix="/activities/"' \
+ 'android:path="/users/log-in"' \
+ 'android:path="/safety"'; do
+ require_literal "$manifest" "$route" \
+ "the verified App Link allowlist is missing $route"
+done
+
+if grep -Fq 'android:pathPrefix="/auth' "$manifest" ||
+ grep -Fq 'android:path="/auth' "$manifest"; then
+ echo "Android Play policy check failed: browser authentication callbacks are claimed as App Links." >&2
+ exit 1
+fi
+
if grep -Fq 'android.permission.ACCESS_BACKGROUND_LOCATION' "$manifest"; then
echo "Android Play policy check failed: ACCESS_BACKGROUND_LOCATION was added." >&2
echo "The reviewed flow starts a user-visible location foreground service from the foreground; adding background permission requires a new policy and product review." >&2
diff --git a/scripts/android-staging-smoke.sh b/scripts/android-staging-smoke.sh
index 683aab5..01eee2a 100755
--- a/scripts/android-staging-smoke.sh
+++ b/scripts/android-staging-smoke.sh
@@ -228,6 +228,7 @@ fi
same_origin="$WNH_BASE_URL/safety"
external_origin=https://example.com/
+browser_callback="$WNH_BASE_URL/auth/google/callback?code=verification&state=browser"
if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then
echo "The $variant APK does not declare its exact HTTPS host." >&2
@@ -272,6 +273,13 @@ docker exec "$container" adb shell cmd package resolve-activity --brief \
-c android.intent.category.BROWSABLE \
-d "$external_origin" >"$output/external-origin-resolver.txt"
+docker exec "$container" adb shell cmd package resolve-activity --brief \
+ --user 0 \
+ -a android.intent.action.VIEW \
+ -c android.intent.category.DEFAULT \
+ -c android.intent.category.BROWSABLE \
+ -d "$browser_callback" >"$output/browser-callback-resolver.txt"
+
if ! grep -Fq "Activity: $package/$activity" "$output/same-origin-app-link-start.txt"; then
echo "The verified $variant App Link did not open the application." >&2
exit 1
@@ -282,6 +290,11 @@ if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then
exit 1
fi
+if grep -Fq "$package/" "$output/browser-callback-resolver.txt"; then
+ echo "The $variant APK incorrectly claimed the browser-only Google OAuth callback." >&2
+ exit 1
+fi
+
docker exec "$container" adb logcat -c
docker exec "$container" adb shell am start -W \
-n "$package/$activity" >"$output/home-start.txt"
diff --git a/scripts/quality.sh b/scripts/quality.sh
index 62d1682..1963a7b 100755
--- a/scripts/quality.sh
+++ b/scripts/quality.sh
@@ -569,7 +569,11 @@ REPORT
echo "App Link resolution omitted a required intent category." >&2
exit 1
fi
- printf '%s\n' "${FAKE_PLAY_ACTIVITY:-org.whoneedhelp.mobile/.MainActivity}"
+ if [[ " $* " == *"/auth/google/callback"* ]]; then
+ printf '%s\n' "${FAKE_PLAY_CALLBACK_ACTIVITY:-com.android.browser/.BrowserActivity}"
+ else
+ printf '%s\n' "${FAKE_PLAY_ACTIVITY:-org.whoneedhelp.mobile/.MainActivity}"
+ fi
;;
*)
printf 'unexpected adb command: %s\n' "$*" >&2
@@ -628,6 +632,15 @@ if WNH_ADB_BIN="$fake_play_adb" \
exit 1
fi
+if WNH_ADB_BIN="$fake_play_adb" \
+ FAKE_PLAY_SIGNATURE="$play_sha256_one_colon" \
+ FAKE_PLAY_CALLBACK_ACTIVITY=org.whoneedhelp.mobile/.MainActivity \
+ ./scripts/verify-play-installed-android.sh \
+ "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then
+ echo "Play-installed verifier accepted an Android-claimed browser OAuth callback." >&2
+ exit 1
+fi
+
echo "Checking Android environment isolation"
./scripts/android-play-policy-check.sh >/dev/null
android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF
diff --git a/scripts/verify-play-installed-android.sh b/scripts/verify-play-installed-android.sh
index abecd6b..b220c95 100755
--- a/scripts/verify-play-installed-android.sh
+++ b/scripts/verify-play-installed-android.sh
@@ -26,6 +26,7 @@ expected_version_name=$4
package_name=org.whoneedhelp.mobile
app_link_host=whoneedhelp.com
app_link_url=https://whoneedhelp.com/safety
+browser_callback_url='https://whoneedhelp.com/auth/google/callback?code=verification&state=browser'
expected_activity=org.whoneedhelp.mobile/.MainActivity
adb_bin=${WNH_ADB_BIN:-adb}
@@ -177,9 +178,23 @@ if ! grep -Fx "$expected_activity" <<<"$resolved_activity" >/dev/null; then
exit 1
fi
+callback_activity=$(
+ adb_device shell cmd package resolve-activity --brief \
+ -a android.intent.action.VIEW \
+ -c android.intent.category.DEFAULT \
+ -c android.intent.category.BROWSABLE \
+ -d "$browser_callback_url" |
+ tr -d '\r'
+)
+if grep -Fx "$expected_activity" <<<"$callback_activity" >/dev/null; then
+ echo "The browser-only Google OAuth callback is incorrectly claimed by the Android app." >&2
+ exit 1
+fi
+
echo "Google Play installed Android verification passed."
echo "Package: $package_name"
echo "Version: $observed_version_name ($observed_version_code)"
echo "Installer: Google Play"
echo "Signing identity: supplied Play App Signing set member"
echo "App Link: $app_link_host verified and resolved to MainActivity"
+echo "Browser OAuth callback: not claimed by MainActivity"
diff --git a/test/who_need_help_web/controllers/android_app_links_controller_test.exs b/test/who_need_help_web/controllers/android_app_links_controller_test.exs
index df3ddf4..743dddf 100644
--- a/test/who_need_help_web/controllers/android_app_links_controller_test.exs
+++ b/test/who_need_help_web/controllers/android_app_links_controller_test.exs
@@ -40,6 +40,26 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksControllerTest do
"sha256_cert_fingerprints" => [fingerprint]
}
+ assert get_in(statement, [
+ "relation_extensions",
+ "delegate_permission/common.handle_all_urls",
+ "dynamic_app_link_components"
+ ]) == [
+ %{"/" => "/requests"},
+ %{"/" => "/requests/*"},
+ %{"/" => "/activities"},
+ %{"/" => "/activities/*"},
+ %{"/" => "/notifications"},
+ %{"/" => "/reports"},
+ %{"/" => "/users/log-in"},
+ %{"/" => "/safety"},
+ %{"/" => "/profile"},
+ %{"/" => "/people/*"},
+ %{"/" => "/leaderboard"},
+ %{"/" => "/categories/proposals"},
+ %{"/" => "*", "exclude" => true}
+ ]
+
assert get_resp_header(conn, "cache-control") == ["public, max-age=300"]
end
end