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" /> +