mirror of
git://f0xx.org/ac/ac-mobile-android
synced 2026-07-29 01:47:35 +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:
@@ -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'
|
||||
|
||||
|
||||
@@ -67,6 +67,20 @@
|
||||
android:label="@string/developer_settings_title"
|
||||
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
|
||||
android:name=".stats.ui.SessionStatsAnalyzerActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -94,6 +94,11 @@ public class CastSettingsFragment extends Fragment {
|
||||
crashLogsCheck.setOnCheckedChangeListener((btn, 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() {
|
||||
private boolean first = true;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java
Normal file
141
app/src/main/java/com/foxx/androidcast/qr/QrScanActivity.java
Normal 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;
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/layout/activity_in_app_web.xml
Normal file
10
app/src/main/res/layout/activity_in_app_web.xml
Normal 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>
|
||||
21
app/src/main/res/layout/activity_qr_scan.xml
Normal file
21
app/src/main/res/layout/activity_qr_scan.xml
Normal 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>
|
||||
@@ -95,6 +95,13 @@
|
||||
android:checked="true"
|
||||
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
|
||||
android:id="@+id/header_sender"
|
||||
layout="@layout/include_settings_section_header"
|
||||
|
||||
@@ -192,6 +192,12 @@
|
||||
<string name="label_app_built">Сборка:</string>
|
||||
<string name="label_app_author">Автор:</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="reset_defaults_confirm">Восстановить все настройки приложения по умолчанию?</string>
|
||||
<string name="reset_defaults_done">Настройки приложения сброшены по умолчанию</string>
|
||||
|
||||
@@ -211,6 +211,12 @@
|
||||
<string name="label_app_built">Built:</string>
|
||||
<string name="label_app_author">Author:</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="reset_defaults_confirm">Restore all app settings to default?</string>
|
||||
<string name="reset_defaults_done">App settings restored to default</string>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user