taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
M.idea/compiler.xml | 2+-
Mdonau-verificator/src/main/java/net/taler/donauverificator/MainActivity.java | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mdonau-verificator/src/main/java/net/taler/donauverificator/Results.java | 38++++++++++++++++++++++++++++----------
Mdonau-verificator/src/main/java/net/taler/donauverificator/SettingsActivity.java | 1+
Mdonau-verificator/src/main/java/net/taler/donauverificator/network/DonauNetworkClient.java | 19++++++-------------
Mdonau-verificator/src/main/res/values/strings.xml | 11+++++++++++
Mdonau-verificator/src/main/res/xml/preferences.xml | 9+++++++++
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"