commit 0038c408891fb16e144e6fc10d022174325b92ab
parent 8269c32574d406b927603511df197d0468215e5f
Author: Bohdan Potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Thu, 16 Oct 2025 21:54:02 +0200
[donau-verificator] Array of signkey support + Camera permission bug + Possibility to skip url confirmation screen
Diffstat:
7 files changed, 222 insertions(+), 66 deletions(-)
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
@@ -11,7 +11,7 @@
<entry name="!?*.kt" />
<entry name="!?*.clj" />
</wildcardResourcePatterns>
- <bytecodeTargetLevel target="21">
+ <bytecodeTargetLevel target="17">
<module name="common_commonMain" target="1.6" />
<module name="common_commonTest" target="1.6" />
<module name="common_jvmMain" target="1.6" />
diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/MainActivity.java b/donau-verificator/src/main/java/net/taler/donauverificator/MainActivity.java
@@ -23,7 +23,10 @@ import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
+import android.content.SharedPreferences;
import android.os.Bundle;
+import android.provider.Settings;
+import android.net.Uri;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@@ -38,15 +41,19 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
+import androidx.preference.PreferenceManager;
import net.taler.donauverificator.databinding.ActivityMainBinding;
public class MainActivity extends AppCompatActivity {
// private static final String DEBUG_DONATION_STATEMENT =
// "donau://example.com/megacharity/1234/2025/7560001010000/1234?total=EUR:15&sig=ED25519:H9PM3BW3P8MEKB34GZ0G1F7JSNVX7B8AHXRFFMS37QZM7TXZ5MWPXTEDZZGN1QRB1AFPKNCFXJB39NJHP3BAFGCZSCXHEYPHA1YJY28&pub=K641W1CZM7DRSV184M8CPM3Z8MZRBYYJMNYMJK70FTYJHBPX21J0";
- private static final String DEBUG_DONATION_STATEMENT =
- "donau://donau.test.taler.net/2025/123%2F456%2F789/AWNFDRFT0WX45W4Y32A9DJA03S1EF66GFQZ9EV5EF9JTHWZ37WR0?total=TESTKUDOS:1&sig=ED25519:B14WGS43FFPEB8JMSR6W1H8M6KH9AV33JFH376R6PM2MNH4GR24FP1C93C4ZPDG21W5WY4SASZQ4CRS427F4WJZJFZMQ5Y4HZNXGY30";
- private int PERMISSIONS_REQUEST_CAMERA = 0;
+// private static final String DEBUG_DONATION_STATEMENT =
+// "donau://donau.test.taler.net/2025/123%2F456%2F789/AWNFDRFT0WX45W4Y32A9DJA03S1EF66GFQZ9EV5EF9JTHWZ37WR0?total=TESTKUDOS:1&sig=ED25519:B14WGS43FFPEB8JMSR6W1H8M6KH9AV33JFH376R6PM2MNH4GR24FP1C93C4ZPDG21W5WY4SASZQ4CRS427F4WJZJFZMQ5Y4HZNXGY30";
+ private static final int REQ_CAMERA_PERMISSION = 100;
+ private static final int REQ_APP_SETTINGS = 101;
+ private static final String PREFS_PERMISSIONS = "permissions_prefs";
+ private static final String KEY_CAMERA_DENIALS = "camera_denials";
private ActivityMainBinding binding;
private CodeScanner mCodeScanner;
@@ -58,26 +65,21 @@ public class MainActivity extends AppCompatActivity {
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
- //Check if CAMERA permission is granted
- if (ContextCompat.checkSelfPermission(this, "android.permission.CAMERA") == PERMISSION_GRANTED) {
- } else {
- askPermission();
+ // Check if CAMERA permission is granted; otherwise start permission flow
+ if (!hasCameraPermission()) {
+ askForCameraPermission();
}
CodeScannerView scannerView = binding.scannerView;
mCodeScanner = new CodeScanner(this, scannerView);
mCodeScanner.setDecodeCallback(new DecodeCallback() {
@Override
public void onDecoded(@NonNull final Result result) {
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- sendRequestDialog(result.getText());
- }
- });
+ runOnUiThread(() -> handleScannedText(result.getText()));
}
});
+
//temporary for debugging (valid donation statement from sample QR code)
- sendRequestDialog(DEBUG_DONATION_STATEMENT);
+ //sendRequestDialog(DEBUG_DONATION_STATEMENT);
binding.settingsButton.setOnClickListener(v -> {
Intent intent = new Intent(this, SettingsActivity.class);
@@ -89,7 +91,9 @@ public class MainActivity extends AppCompatActivity {
@Override
public void onResume() {
super.onResume();
- mCodeScanner.startPreview();
+ if (hasCameraPermission()) {
+ mCodeScanner.startPreview();
+ }
}
@Override
@@ -114,15 +118,14 @@ public class MainActivity extends AppCompatActivity {
switch (which) {
case DialogInterface.BUTTON_POSITIVE:
//Yes button clicked
- Intent intent = new Intent(getApplicationContext(), Results.class);
- intent.putExtra("QR-String", qrstring);
- startActivity(intent);
+ startResults(qrstring);
break;
case DialogInterface.BUTTON_NEGATIVE:
//No button clicked
- mCodeScanner.releaseResources();
- mCodeScanner.startPreview();
+ if (mCodeScanner != null) {
+ mCodeScanner.startPreview();
+ }
break;
default:
throw new IllegalStateException("Unexpected value: " + which);
@@ -135,35 +138,156 @@ public class MainActivity extends AppCompatActivity {
}
/**
- * Ask user to get camera permissions
- * Handles permission lifecycle
+ * Handle scanned QR content: if it's a valid Donau URI, proceed directly to Results.
+ * Otherwise, show a brief message and resume scanning.
*/
- private void askPermission() {
+ private void handleScannedText(String qrString) {
+ if (isValidDonauUri(qrString)) {
+ boolean autoOpen = PreferenceManager.getDefaultSharedPreferences(this)
+ .getBoolean(SettingsActivity.KEY_AUTO_OPEN_DONAU, true);
+ if (autoOpen) {
+ startResults(qrString);
+ } else {
+ sendRequestDialog(qrString);
+ }
+ return;
+ }
+ Toast.makeText(this, R.string.scan_not_donau_link, Toast.LENGTH_SHORT).show();
+ if (mCodeScanner != null) {
+ mCodeScanner.startPreview();
+ }
+ }
- //Checks if a RequestPermissionRationale should be shown
- if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
+ private void startResults(String qrString) {
+ Intent intent = new Intent(getApplicationContext(), Results.class);
+ intent.putExtra("QR-String", qrString);
+ startActivity(intent);
+ }
- AlertDialog.Builder alert = new AlertDialog.Builder(getApplicationContext());
- alert.setTitle("Please grant camera permission");
- alert.setMessage("This APP needs the camera permission to scan QR codes.");
- alert.setNeutralButton("OK", new DialogInterface.OnClickListener() {
- public void onClick(DialogInterface dialog, int id) {
- checkPermission("android.permission.CAMERA", PERMISSIONS_REQUEST_CAMERA);
- }
- });
- alert.setIcon(android.R.drawable.ic_dialog_alert);
- alert.show();
- } else {
- //A permission request should be performed
- checkPermission("android.permission.CAMERA", PERMISSIONS_REQUEST_CAMERA);
+ private boolean isValidDonauUri(String raw) {
+ if (raw == null) return false;
+ Uri uri;
+ try {
+ uri = Uri.parse(raw);
+ } catch (Exception e) {
+ return false;
}
+ if (uri == null) return false;
+ String scheme = uri.getScheme();
+ if (scheme == null) return false;
+ String lowered = scheme.toLowerCase();
+ if (!("donau".equals(lowered) || "donau+http".equals(lowered))) {
+ return false;
+ }
+ if (uri.getHost() == null || uri.getHost().trim().isEmpty()) {
+ return false;
+ }
+ java.util.List<String> segments = uri.getPathSegments();
+ if (segments == null || segments.size() < 3) {
+ return false;
+ }
+ String year = segments.get(segments.size() - 3);
+ if (!isFourDigitYear(year)) {
+ return false;
+ }
+ String taxId = segments.get(segments.size() - 2);
+ String salt = segments.get(segments.size() - 1);
+ if (taxId == null || taxId.isEmpty()) return false;
+ if (salt == null || salt.trim().isEmpty()) return false;
+ return true;
+ }
+
+ private boolean isFourDigitYear(String value) {
+ if (value == null || value.length() != 4) return false;
+ for (int i = 0; i < value.length(); i++) {
+ if (!Character.isDigit(value.charAt(i))) return false;
+ }
+ return true;
}
- private void checkPermission(String permission, int requestCode) {
- if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_DENIED) {
- ActivityCompat.requestPermissions(this, new String[] {permission}, requestCode);
+ private boolean hasCameraPermission() {
+ return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ /**
+ * Request camera permission with rationale and fallback to app settings after repeated denials.
+ */
+ private void askForCameraPermission() {
+ if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
+ showPermissionRationaleDialog();
} else {
- Toast.makeText(this, "Permission already granted", Toast.LENGTH_SHORT).show();
+ requestCameraPermission();
+ }
+ }
+
+ private void requestCameraPermission() {
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQ_CAMERA_PERMISSION);
+ }
+
+ private void showPermissionRationaleDialog() {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.permission_camera_title)
+ .setMessage(getString(R.string.permission_camera_message))
+ .setPositiveButton(R.string.permission_continue, (d, w) -> requestCameraPermission())
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void showGoToSettingsDialog() {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.permission_camera_denied_title)
+ .setMessage(getString(R.string.permission_camera_denied_message))
+ .setPositiveButton(R.string.permission_open_settings, (d, w) -> openAppSettings())
+ .setNegativeButton(android.R.string.cancel, null)
+ .setIcon(android.R.drawable.ic_dialog_alert)
+ .show();
+ }
+
+ private void openAppSettings() {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ Uri uri = Uri.fromParts("package", getPackageName(), null);
+ intent.setData(uri);
+ startActivity(intent);
+ }
+
+ private void incrementCameraDenialCount() {
+ SharedPreferences prefs = getSharedPreferences(PREFS_PERMISSIONS, MODE_PRIVATE);
+ int count = prefs.getInt(KEY_CAMERA_DENIALS, 0);
+ prefs.edit().putInt(KEY_CAMERA_DENIALS, count + 1).apply();
+ }
+
+ private int getCameraDenialCount() {
+ SharedPreferences prefs = getSharedPreferences(PREFS_PERMISSIONS, MODE_PRIVATE);
+ return prefs.getInt(KEY_CAMERA_DENIALS, 0);
+ }
+
+ private void resetCameraDenialCount() {
+ SharedPreferences prefs = getSharedPreferences(PREFS_PERMISSIONS, MODE_PRIVATE);
+ prefs.edit().remove(KEY_CAMERA_DENIALS).apply();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == REQ_CAMERA_PERMISSION) {
+ boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ if (granted) {
+ resetCameraDenialCount();
+ Toast.makeText(this, R.string.permission_granted, Toast.LENGTH_SHORT).show();
+ if (mCodeScanner != null) {
+ mCodeScanner.startPreview();
+ }
+ } else {
+ incrementCameraDenialCount();
+ boolean showRationale = ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA);
+ if (!showRationale || getCameraDenialCount() >= 2) {
+ // User has denied twice or selected "Don't ask again" — guide to Settings
+ showGoToSettingsDialog();
+ } else {
+ // Show rationale again for clarity
+ showPermissionRationaleDialog();
+ }
+ }
}
}
diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/Results.java b/donau-verificator/src/main/java/net/taler/donauverificator/Results.java
@@ -71,6 +71,7 @@ public class Results extends AppCompatActivity {
private String salt;
private String eddsaSignature;
private String publicKey;
+ private final List<String> publicKeys = new ArrayList<>();
private DonauNetworkClient networkClient;
TextView sigStatusView;
View summaryContainer;
@@ -152,15 +153,31 @@ public class Results extends AppCompatActivity {
}
private void checkSignature() throws Exception{
+ if (!isEmpty(publicKey)) {
+ int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, publicKey);
+ if (res == 0) {
+ statusHandling(SignatureStatus.SIGNATURE_VALID);
+ } else {
+ statusHandling(SignatureStatus.SIGNATURE_INVALID);
+ }
+ return;
+ }
- int res = ed25519_verify(year, totalAmount, taxId,
- salt, eddsaSignature, publicKey);
- System.out.println("Result: " + res);
- if (res == 0) {
- statusHandling(SignatureStatus.SIGNATURE_VALID);
- } else {
+ if (publicKeys.isEmpty()) {
statusHandling(SignatureStatus.SIGNATURE_INVALID);
+ return;
+ }
+
+ for (String candidate : publicKeys) {
+ if (isEmpty(candidate)) continue;
+ int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, candidate);
+ if (res == 0) {
+ publicKey = candidate; // remember the matching key for UI/details
+ statusHandling(SignatureStatus.SIGNATURE_VALID);
+ return;
+ }
}
+ statusHandling(SignatureStatus.SIGNATURE_INVALID);
}
private void startVerificationAsync() {
@@ -361,6 +378,7 @@ public class Results extends AppCompatActivity {
salt = null;
eddsaSignature = null;
publicKey = null;
+ publicKeys.clear();
networkClient = null;
}
@@ -469,12 +487,12 @@ public class Results extends AppCompatActivity {
try {
DonauNetworkClient client = getNetworkClient();
- int targetYear = parseYearOrDefault(year);
- String fetchedKey = client.fetchSigningKey(targetYear);
- if (isEmpty(fetchedKey)) {
+ List<String> fetched = client.fetchSigningKeys();
+ if (fetched == null || fetched.isEmpty()) {
return SignatureStatus.KEY_NOT_FOUND;
}
- publicKey = fetchedKey;
+ publicKeys.clear();
+ publicKeys.addAll(fetched);
return null;
} catch (HttpStatusException e) {
Log.e(TAG, "Failed to download Donau signing keys, HTTP " + e.getStatusCode(), e);
diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/SettingsActivity.java b/donau-verificator/src/main/java/net/taler/donauverificator/SettingsActivity.java
@@ -28,6 +28,7 @@ import androidx.preference.PreferenceFragmentCompat;
public class SettingsActivity extends AppCompatActivity {
public static final String KEY_DEVELOPER_MODE = "pref_developer_mode";
+ public static final String KEY_AUTO_OPEN_DONAU = "pref_auto_open_donau";
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
diff --git a/donau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java b/donau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java
@@ -42,7 +42,7 @@ public final class DonauNetworkClient {
}
}
- public String fetchSigningKey(int targetYear) throws IOException, JSONException {
+ public List<String> fetchSigningKeys() throws IOException, JSONException {
URL url = buildUrl("keys");
HttpURLConnection connection = openGetConnection(url);
connection.setRequestProperty("Accept", "application/json");
@@ -55,24 +55,17 @@ public final class DonauNetworkClient {
JSONObject json = new JSONObject(body);
JSONArray signkeys = json.optJSONArray("signkeys");
if (signkeys == null) {
- return null;
+ return Collections.emptyList();
}
- String fallback = null;
+ List<String> result = new ArrayList<>();
for (int i = 0; i < signkeys.length(); i++) {
Object entry = signkeys.get(i);
String keyCandidate = extractSigningKey(entry);
- if (keyCandidate == null) {
- continue;
- }
- int yearCandidate = extractYear(entry);
- if (targetYear != Integer.MIN_VALUE && yearCandidate == targetYear) {
- return keyCandidate;
- }
- if (fallback == null) {
- fallback = keyCandidate;
+ if (keyCandidate != null) {
+ result.add(keyCandidate);
}
}
- return fallback;
+ return result;
} finally {
connection.disconnect();
}
diff --git a/donau-verificator/src/main/res/values/strings.xml b/donau-verificator/src/main/res/values/strings.xml
@@ -12,6 +12,9 @@
<string name="pref_developer_mode_title">Developer mode</string>
<string name="pref_developer_mode_summary_on">Developer features enabled; insecure Donau URIs allowed.</string>
<string name="pref_developer_mode_summary_off">Developer features disabled.</string>
+ <string name="pref_auto_open_donau_title">Auto-open Donau links</string>
+ <string name="pref_auto_open_donau_summary_on">Valid Donau QR codes open the Results screen automatically.</string>
+ <string name="pref_auto_open_donau_summary_off">Ask for confirmation before opening Donau links.</string>
<string name="status_insecure_http_disabled">Insecure Donau URI not allowed. Enable developer mode to proceed.</string>
<string name="status_insecure_http_unsupported">This build does not support insecure Donau URIs.</string>
<string name="status_key_download_failed">Unable to fetch Donau signing keys.</string>
@@ -31,4 +34,12 @@
<string name="signature_info_public_key">Public key: %1$s</string>
<string name="value_unknown">Unknown</string>
<string name="title_donau_verifier">Donau Verifier</string>
+ <string name="permission_camera_title">Camera permission required</string>
+ <string name="permission_camera_message">To scan donation QR codes, the app needs access to your camera. Images are used only for scanning and are not saved.</string>
+ <string name="permission_camera_denied_title">Enable camera in Settings</string>
+ <string name="permission_camera_denied_message">Camera access is necessary to scan QR codes. Please enable the Camera permission for Donau Verify in your device settings.</string>
+ <string name="permission_open_settings">Open Settings</string>
+ <string name="permission_continue">Continue</string>
+ <string name="permission_granted">Permission granted</string>
+ <string name="scan_not_donau_link">Scanned code is not a valid Donau link</string>
</resources>
diff --git a/donau-verificator/src/main/res/xml/preferences.xml b/donau-verificator/src/main/res/xml/preferences.xml
@@ -2,6 +2,15 @@
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.preference.SwitchPreferenceCompat
+ android:key="pref_auto_open_donau"
+ android:title="@string/pref_auto_open_donau_title"
+ android:summaryOn="@string/pref_auto_open_donau_summary_on"
+ android:summaryOff="@string/pref_auto_open_donau_summary_off"
+ android:defaultValue="true"
+ android:icon="@drawable/ic_info"
+ app:iconSpaceReserved="true"
+ app:isPreferenceVisible="true" />
+ <androidx.preference.SwitchPreferenceCompat
android:key="pref_developer_mode"
android:title="@string/pref_developer_mode_title"
android:summaryOn="@string/pref_developer_mode_summary_on"