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:
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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: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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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