diff --git a/app/build.gradle b/app/build.gradle
index d07ffca..cc953b8 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -142,6 +142,13 @@ dependencies {
implementation 'com.android.billingclient:billing:7.1.1'
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 'org.json:json:20240303'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2e7a8b2..1d3180f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,6 +67,20 @@
android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" />
+
+
+
+
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() {
private boolean first = true;
diff --git a/app/src/main/java/com/foxx/androidcast/network/AcAppLinkRouter.java b/app/src/main/java/com/foxx/androidcast/network/AcAppLinkRouter.java
new file mode 100644
index 0000000..d483c3c
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/network/AcAppLinkRouter.java
@@ -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);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/qr/InAppWebActivity.java b/app/src/main/java/com/foxx/androidcast/qr/InAppWebActivity.java
new file mode 100644
index 0000000..11593a9
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/qr/InAppWebActivity.java
@@ -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();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java b/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java
new file mode 100644
index 0000000..c04b236
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java
@@ -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 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 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;
+ }
+}
diff --git a/app/src/main/res/layout/activity_in_app_web.xml b/app/src/main/res/layout/activity_in_app_web.xml
new file mode 100644
index 0000000..6a04f44
--- /dev/null
+++ b/app/src/main/res/layout/activity_in_app_web.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_qr_scan.xml b/app/src/main/res/layout/activity_qr_scan.xml
new file mode 100644
index 0000000..3abe5d6
--- /dev/null
+++ b/app/src/main/res/layout/activity_qr_scan.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_cast_settings.xml b/app/src/main/res/layout/fragment_cast_settings.xml
index ff9991a..1402dde 100644
--- a/app/src/main/res/layout/fragment_cast_settings.xml
+++ b/app/src/main/res/layout/fragment_cast_settings.xml
@@ -95,6 +95,13 @@
android:checked="true"
android:text="@string/label_send_anonymous_crash_logs" />
+
+
Сборка:
Автор:
Лицензии
+ Сканировать QR
+ Сканер QR
+ Наведите на QR — ссылки подтверждения входа откроются в приложении
+ Для сканирования QR нужен доступ к камере
+ Не удалось запустить камеру
+ Android Cast
Сбросить по умолчанию
Восстановить все настройки приложения по умолчанию?
Настройки приложения сброшены по умолчанию
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a0624bd..07743a4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -211,6 +211,12 @@
Built:
Author:
Licenses
+ Scan QR code
+ Scan QR code
+ Point at a QR code — sign-in approval links open inside the app
+ Camera permission is required to scan QR codes
+ Could not start the camera
+ Android Cast
Reset to defaults
Restore all app settings to default?
App settings restored to default
diff --git a/app/src/test/java/com/foxx/androidcast/network/AcAppLinkRouterTest.java b/app/src/test/java/com/foxx/androidcast/network/AcAppLinkRouterTest.java
new file mode 100644
index 0000000..3aea914
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/network/AcAppLinkRouterTest.java
@@ -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"));
+ }
+}