1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-07-29 03:37:51 +03:00

Add in-app QR scanner with internal routing for auth links.

CameraX + ML Kit scan from settings; 2FA approve and verify-email URLs open in-app WebView, others use the system browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 14:21:14 +02:00
parent 2a5b46a212
commit 194e558260
12 changed files with 454 additions and 0 deletions

View File

@@ -142,6 +142,13 @@ dependencies {
implementation 'com.android.billingclient:billing:7.1.1' implementation 'com.android.billingclient:billing:7.1.1'
implementation 'com.google.android.play:review:2.0.2' implementation 'com.google.android.play:review:2.0.2'
def camerax_version = '1.3.4'
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
implementation 'com.google.mlkit:barcode-scanning:17.3.0'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303' testImplementation 'org.json:json:20240303'

View File

@@ -67,6 +67,20 @@
android:label="@string/developer_settings_title" android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" /> android:parentActivityName=".MainActivity" />
<activity
android:name=".qr.QrScanActivity"
android:exported="false"
android:label="@string/qr_scan_title"
android:parentActivityName=".MainActivity"
android:screenOrientation="portrait" />
<activity
android:name=".qr.InAppWebActivity"
android:exported="false"
android:label="@string/in_app_web_title"
android:parentActivityName=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity <activity
android:name=".stats.ui.SessionStatsAnalyzerActivity" android:name=".stats.ui.SessionStatsAnalyzerActivity"
android:exported="false" android:exported="false"

View File

@@ -94,6 +94,11 @@ public class CastSettingsFragment extends Fragment {
crashLogsCheck.setOnCheckedChangeListener((btn, checked) -> crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->
AppPreferences.setSendAnonymousCrashLogs(requireContext(), checked)); AppPreferences.setSendAnonymousCrashLogs(requireContext(), checked));
} }
View scanQrBtn = view.findViewById(R.id.btn_scan_qr);
if (scanQrBtn != null) {
scanQrBtn.setOnClickListener(v ->
startActivity(new Intent(requireContext(), com.foxx.androidcast.qr.QrScanActivity.class)));
}
themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
private boolean first = true; private boolean first = true;

View File

@@ -0,0 +1,124 @@
package com.foxx.androidcast.network;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.foxx.androidcast.qr.InAppWebActivity;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Locale;
/** Routes scanned QR payloads: in-app for our auth flows, external browser otherwise. */
public final class AcAppLinkRouter {
private static final String TAG = "AcAppLinkRouter";
public enum Kind {
TWO_FACTOR_APPROVE,
VERIFY_EMAIL,
EXTERNAL
}
private AcAppLinkRouter() {}
public static void open(@NonNull Context context, @NonNull String rawPayload) {
String payload = rawPayload.trim();
if (payload.isEmpty()) {
return;
}
if (payload.startsWith("otpauth:")) {
openExternal(context, payload);
return;
}
new Thread(() -> {
String resolved = resolveFinalUrl(payload);
Kind kind = classify(resolved);
new Handler(Looper.getMainLooper()).post(() -> {
if (kind == Kind.EXTERNAL) {
openExternal(context, resolved);
} else {
context.startActivity(InAppWebActivity.intent(context, resolved));
}
});
}, "ac-qr-route").start();
}
@NonNull
public static Kind classify(@Nullable String url) {
if (url == null || url.isEmpty()) {
return Kind.EXTERNAL;
}
String host;
String path;
try {
URL parsed = new URL(url);
host = parsed.getHost();
path = parsed.getPath() != null ? parsed.getPath() : "";
} catch (MalformedURLException e) {
return Kind.EXTERNAL;
}
if (host == null || !isOurHost(host)) {
return Kind.EXTERNAL;
}
path = path.toLowerCase(Locale.US);
if (path.contains("/two-factor/approve")) {
return Kind.TWO_FACTOR_APPROVE;
}
if (path.contains("/verify-email") || path.contains("/register/verify")
|| path.contains("/email/verify")) {
return Kind.VERIFY_EMAIL;
}
return Kind.EXTERNAL;
}
static boolean isOurHost(@NonNull String host) {
String h = host.toLowerCase(Locale.US);
if (h.equals(BackendEndpoints.APPS_HOST)) {
return true;
}
if (h.equals("s.f0xx.org")) {
return true;
}
if (h.endsWith(".f0xx.org") || h.endsWith(".intra.raptor.org")) {
return true;
}
return false;
}
@NonNull
static String resolveFinalUrl(@NonNull String url) {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setInstanceFollowRedirects(true);
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(10_000);
conn.setRequestProperty("User-Agent", "AndroidCast/QR");
conn.connect();
return conn.getURL().toString();
} catch (IOException e) {
Log.w(TAG, "resolveFinalUrl failed for " + url + ": " + e.getMessage());
return url;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static void openExternal(@NonNull Context context, @NonNull String url) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
}

View File

@@ -0,0 +1,82 @@
package com.foxx.androidcast.qr;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.CastThemeHelper;
import com.foxx.androidcast.R;
import com.foxx.androidcast.network.AcAppLinkRouter;
/** In-app WebView for our auth QR flows (2FA approve, email verify). */
public final class InAppWebActivity extends AppCompatActivity {
public static final String EXTRA_URL = "url";
public static Intent intent(@NonNull Context context, @NonNull String url) {
return new Intent(context, InAppWebActivity.class)
.putExtra(EXTRA_URL, url);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
CastThemeHelper.applyTheme(this);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_in_app_web);
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.in_app_web_title);
}
String url = getIntent().getStringExtra(EXTRA_URL);
if (url == null || url.isEmpty()) {
finish();
return;
}
WebView web = findViewById(R.id.in_app_webview);
configureWebView(web);
web.loadUrl(url);
}
@SuppressLint("SetJavaScriptEnabled")
private void configureWebView(@NonNull WebView web) {
WebSettings settings = web.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
web.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(@NonNull WebView view, @NonNull WebResourceRequest request) {
String next = request.getUrl().toString();
if (AcAppLinkRouter.classify(next) == AcAppLinkRouter.Kind.EXTERNAL) {
startActivity(new Intent(Intent.ACTION_VIEW, request.getUrl()));
return true;
}
return false;
}
});
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
@Override
public void onBackPressed() {
WebView web = findViewById(R.id.in_app_webview);
if (web != null && web.canGoBack()) {
web.goBack();
} else {
super.onBackPressed();
}
}
}

View File

@@ -0,0 +1,141 @@
package com.foxx.androidcast.qr;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.util.Size;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView;
import androidx.core.content.ContextCompat;
import com.foxx.androidcast.CastThemeHelper;
import com.foxx.androidcast.R;
import com.foxx.androidcast.network.AcAppLinkRouter;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.mlkit.vision.barcode.BarcodeScanner;
import com.google.mlkit.vision.barcode.BarcodeScannerOptions;
import com.google.mlkit.vision.barcode.BarcodeScanning;
import com.google.mlkit.vision.barcode.common.Barcode;
import com.google.mlkit.vision.common.InputImage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** Camera QR scanner — routes our auth URLs in-app. */
public final class QrScanActivity extends AppCompatActivity {
private final AtomicBoolean handled = new AtomicBoolean(false);
private ExecutorService cameraExecutor;
private BarcodeScanner scanner;
private final ActivityResultLauncher<String> cameraPermission = registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> {
if (granted) {
startCamera();
} else {
Toast.makeText(this, R.string.qr_scan_camera_denied, Toast.LENGTH_LONG).show();
finish();
}
});
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
CastThemeHelper.applyTheme(this);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_qr_scan);
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.qr_scan_title);
}
cameraExecutor = Executors.newSingleThreadExecutor();
BarcodeScannerOptions options = new BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build();
scanner = BarcodeScanning.getClient(options);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) {
startCamera();
} else {
cameraPermission.launch(Manifest.permission.CAMERA);
}
}
private void startCamera() {
PreviewView previewView = findViewById(R.id.qr_preview);
ListenableFuture<ProcessCameraProvider> future = ProcessCameraProvider.getInstance(this);
future.addListener(() -> {
try {
ProcessCameraProvider provider = future.get();
bindCamera(provider, previewView);
} catch (Exception e) {
Toast.makeText(this, R.string.qr_scan_camera_error, Toast.LENGTH_LONG).show();
finish();
}
}, ContextCompat.getMainExecutor(this));
}
private void bindCamera(@NonNull ProcessCameraProvider provider, @NonNull PreviewView previewView) {
provider.unbindAll();
Preview preview = new Preview.Builder().build();
preview.setSurfaceProvider(previewView.getSurfaceProvider());
ImageAnalysis analysis = new ImageAnalysis.Builder()
.setTargetResolution(new Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
analysis.setAnalyzer(cameraExecutor, imageProxy -> {
if (handled.get()) {
imageProxy.close();
return;
}
InputImage image = InputImage.fromMediaImage(
imageProxy.getImage(), imageProxy.getImageInfo().getRotationDegrees());
scanner.process(image)
.addOnSuccessListener(barcodes -> {
for (Barcode barcode : barcodes) {
String raw = barcode.getRawValue();
if (raw != null && !raw.isEmpty() && handled.compareAndSet(false, true)) {
runOnUiThread(() -> {
AcAppLinkRouter.open(QrScanActivity.this, raw);
finish();
});
break;
}
}
})
.addOnCompleteListener(task -> imageProxy.close());
});
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis);
}
@Override
protected void onDestroy() {
if (scanner != null) {
scanner.close();
}
if (cameraExecutor != null) {
cameraExecutor.shutdown();
}
super.onDestroy();
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/in_app_webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/qr_preview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#99000000"
android:gravity="center"
android:padding="16dp"
android:text="@string/qr_scan_hint"
android:textColor="@android:color/white"
android:textSize="14sp" />
</FrameLayout>

View File

@@ -95,6 +95,13 @@
android:checked="true" android:checked="true"
android:text="@string/label_send_anonymous_crash_logs" /> android:text="@string/label_send_anonymous_crash_logs" />
<Button
android:id="@+id/btn_scan_qr"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/btn_scan_qr" />
<include <include
android:id="@+id/header_sender" android:id="@+id/header_sender"
layout="@layout/include_settings_section_header" layout="@layout/include_settings_section_header"

View File

@@ -192,6 +192,12 @@
<string name="label_app_built">Сборка:</string> <string name="label_app_built">Сборка:</string>
<string name="label_app_author">Автор:</string> <string name="label_app_author">Автор:</string>
<string name="btn_licenses">Лицензии</string> <string name="btn_licenses">Лицензии</string>
<string name="btn_scan_qr">Сканировать QR</string>
<string name="qr_scan_title">Сканер QR</string>
<string name="qr_scan_hint">Наведите на QR — ссылки подтверждения входа откроются в приложении</string>
<string name="qr_scan_camera_denied">Для сканирования QR нужен доступ к камере</string>
<string name="qr_scan_camera_error">Не удалось запустить камеру</string>
<string name="in_app_web_title">Android Cast</string>
<string name="btn_reset_defaults">Сбросить по умолчанию</string> <string name="btn_reset_defaults">Сбросить по умолчанию</string>
<string name="reset_defaults_confirm">Восстановить все настройки приложения по умолчанию?</string> <string name="reset_defaults_confirm">Восстановить все настройки приложения по умолчанию?</string>
<string name="reset_defaults_done">Настройки приложения сброшены по умолчанию</string> <string name="reset_defaults_done">Настройки приложения сброшены по умолчанию</string>

View File

@@ -211,6 +211,12 @@
<string name="label_app_built">Built:</string> <string name="label_app_built">Built:</string>
<string name="label_app_author">Author:</string> <string name="label_app_author">Author:</string>
<string name="btn_licenses">Licenses</string> <string name="btn_licenses">Licenses</string>
<string name="btn_scan_qr">Scan QR code</string>
<string name="qr_scan_title">Scan QR code</string>
<string name="qr_scan_hint">Point at a QR code — sign-in approval links open inside the app</string>
<string name="qr_scan_camera_denied">Camera permission is required to scan QR codes</string>
<string name="qr_scan_camera_error">Could not start the camera</string>
<string name="in_app_web_title">Android Cast</string>
<string name="btn_reset_defaults">Reset to defaults</string> <string name="btn_reset_defaults">Reset to defaults</string>
<string name="reset_defaults_confirm">Restore all app settings to default?</string> <string name="reset_defaults_confirm">Restore all app settings to default?</string>
<string name="reset_defaults_done">App settings restored to default</string> <string name="reset_defaults_done">App settings restored to default</string>

View File

@@ -0,0 +1,31 @@
package com.foxx.androidcast.network;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class AcAppLinkRouterTest {
@Test
public void classifyTwoFactorApprove() {
assertEquals(AcAppLinkRouter.Kind.TWO_FACTOR_APPROVE, AcAppLinkRouter.classify(
"https://apps.f0xx.org/app/androidcast_project/issues/two-factor/approve?token=abc"));
}
@Test
public void classifyVerifyEmail() {
assertEquals(AcAppLinkRouter.Kind.VERIFY_EMAIL, AcAppLinkRouter.classify(
"https://apps.f0xx.org/app/androidcast_project/issues/verify-email?token=x"));
}
@Test
public void classifyExternalHub() {
assertEquals(AcAppLinkRouter.Kind.EXTERNAL, AcAppLinkRouter.classify(
"https://apps.f0xx.org/app/androidcast_project/issues/"));
}
@Test
public void isOurHostShortener() {
assertTrue(AcAppLinkRouter.isOurHost("s.f0xx.org"));
}
}