taler-android

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

Results.java (25664B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2024 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12 
     13   You should have received a copy of the GNU General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 package net.taler.donauverificator;
     18 
     19 import android.content.Intent;
     20 import android.content.SharedPreferences;
     21 import android.net.Uri;
     22 import android.os.Bundle;
     23 import android.util.Log;
     24 import android.view.View;
     25 import android.widget.TextView;
     26 
     27 import androidx.annotation.ColorRes;
     28 import androidx.annotation.StringRes;
     29 import androidx.appcompat.app.AppCompatActivity;
     30 import androidx.core.content.ContextCompat;
     31 import androidx.preference.PreferenceManager;
     32 
     33 import com.google.android.material.button.MaterialButton;
     34 import com.google.android.material.card.MaterialCardView;
     35 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
     36 
     37 import net.taler.donauverificator.network.CrockfordBase32;
     38 import net.taler.donauverificator.network.DonauNetworkClient;
     39 import net.taler.donauverificator.network.DonauNetworkClient.DonationStatement;
     40 import net.taler.donauverificator.network.DonauNetworkClient.HttpStatusException;
     41 
     42 import org.json.JSONException;
     43 
     44 import java.io.IOException;
     45 import java.net.HttpURLConnection;
     46 import java.nio.charset.StandardCharsets;
     47 import java.security.MessageDigest;
     48 import java.security.NoSuchAlgorithmException;
     49 import java.util.ArrayList;
     50 import java.util.List;
     51 import java.util.Locale;
     52 
     53 public class Results extends AppCompatActivity {
     54     static {
     55         System.loadLibrary("verification");
     56     }
     57 
     58     private static final String TAG = "Results";
     59 
     60     // Format: donau://base/year/taxid-enc/salt?total=...&sig=ED25519:...
     61     // CrockfordBase32 encoded: SIGNATURE, PUBLICKEY
     62 
     63     private String uriScheme;
     64     private String host;
     65     private int port = -1;
     66     private final List<String> authorityPathSegments = new ArrayList<>();
     67     private String year;
     68     private String totalAmount;
     69     private String taxId;
     70     private String hostDisplay;
     71     private String salt;
     72     private String eddsaSignature;
     73     private String publicKey;
     74     private final List<String> publicKeys = new ArrayList<>();
     75     private DonauNetworkClient networkClient;
     76     TextView sigStatusView;
     77     View summaryContainer;
     78     TextView hostLabelView;
     79     TextView hostValueView;
     80     TextView yearLabelView;
     81     TextView yearValueView;
     82     TextView taxLabelView;
     83     TextView taxValueView;
     84     TextView amountLabelView;
     85     TextView amountValueView;
     86     MaterialCardView statusCard;
     87     MaterialButton signatureButton;
     88 
     89     public enum SignatureStatus {
     90         INVALID_SCHEME,
     91         INVALID_NUMBER_OF_ARGUMENTS,
     92         MALFORMED_ARGUMENT,
     93         INSECURE_HTTP_UNSUPPORTED,
     94         INSECURE_HTTP_DISABLED,
     95         KEY_DOWNLOAD_FAILED,
     96         KEY_NOT_FOUND,
     97         DONATION_STATEMENT_DOWNLOAD_FAILED,
     98         DONATION_STATEMENT_NOT_FOUND,
     99         DONATION_STATEMENT_INVALID,
    100         SIGNATURE_INVALID,
    101         SIGNATURE_VALID;
    102     }
    103 
    104 
    105     @Override
    106     protected void onCreate(Bundle savedInstanceState) {
    107         super.onCreate(savedInstanceState);
    108         setContentView(R.layout.fragment_results);
    109         sigStatusView = findViewById(R.id.sigStatus);
    110         statusCard = findViewById(R.id.statusCard);
    111         summaryContainer = findViewById(R.id.summaryContainer);
    112         hostLabelView = findViewById(R.id.hostLabel);
    113         hostValueView = findViewById(R.id.hostValue);
    114         yearLabelView = findViewById(R.id.yearLabel);
    115         yearValueView = findViewById(R.id.yearValue);
    116         taxLabelView = findViewById(R.id.taxLabel);
    117         taxValueView = findViewById(R.id.taxValue);
    118         amountLabelView = findViewById(R.id.amountLabel);
    119         amountValueView = findViewById(R.id.amountValue);
    120         signatureButton = findViewById(R.id.signatureButton);
    121         if (signatureButton != null) {
    122             signatureButton.setVisibility(View.GONE);
    123             signatureButton.setEnabled(false);
    124             signatureButton.setOnClickListener(v -> showSignatureDialog());
    125         }
    126         if (statusCard != null) {
    127             statusCard.setCardBackgroundColor(ContextCompat.getColor(this, R.color.validation_surface_neutral));
    128         }
    129         sigStatusView.setTextColor(ContextCompat.getColor(this, R.color.text_primary));
    130         setSummaryLabels();
    131         setupBackNavigation();
    132 
    133         Intent intent = getIntent();
    134         Uri uri = resolveUri(intent);
    135         if (uri == null) {
    136             statusHandling(SignatureStatus.INVALID_SCHEME);
    137             return;
    138         }
    139 
    140         uriScheme = uri.getScheme();
    141         if (!isSupportedScheme(uriScheme)) {
    142             statusHandling(SignatureStatus.INVALID_SCHEME);
    143             return;
    144         }
    145 
    146         resetParsedFields();
    147         SignatureStatus parseStatus = parseDonauUri(uri);
    148         if (parseStatus != null) {
    149             statusHandling(parseStatus);
    150             return;
    151         }
    152         startVerificationAsync();
    153     }
    154 
    155     private void checkSignature() throws Exception{
    156         if (!isEmpty(publicKey)) {
    157             int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, publicKey);
    158             if (res == 0) {
    159                 statusHandling(SignatureStatus.SIGNATURE_VALID);
    160             } else {
    161                 statusHandling(SignatureStatus.SIGNATURE_INVALID);
    162             }
    163             return;
    164         }
    165 
    166         if (publicKeys.isEmpty()) {
    167             statusHandling(SignatureStatus.SIGNATURE_INVALID);
    168             return;
    169         }
    170 
    171         for (String candidate : publicKeys) {
    172             if (isEmpty(candidate)) continue;
    173             int res = ed25519_verify(year, totalAmount, taxId, salt, eddsaSignature, candidate);
    174             if (res == 0) {
    175                 publicKey = candidate; // remember the matching key for UI/details
    176                 statusHandling(SignatureStatus.SIGNATURE_VALID);
    177                 return;
    178             }
    179         }
    180         statusHandling(SignatureStatus.SIGNATURE_INVALID);
    181     }
    182 
    183     private void startVerificationAsync() {
    184         new Thread(() -> {
    185             SignatureStatus statusResult = ensurePublicKeyAvailable();
    186             if (statusResult == null) {
    187                 statusResult = ensureDonationStatementAvailable();
    188             }
    189             SignatureStatus finalStatus = statusResult;
    190             runOnUiThread(() -> {
    191                 if (isFinishing() || isDestroyed()) {
    192                     return;
    193                 }
    194                 if (finalStatus != null) {
    195                     statusHandling(finalStatus);
    196                     return;
    197                 }
    198                 try {
    199                     checkSignature();
    200                 } catch (Exception e) {
    201                     Log.e(TAG, "Signature verification failed", e);
    202                     statusHandling(SignatureStatus.SIGNATURE_INVALID);
    203                 }
    204             });
    205         }).start();
    206     }
    207 
    208     private void statusHandling(SignatureStatus es) {
    209         switch (es) {
    210             case INVALID_SCHEME:
    211                 updateStatusCard(R.string.invalid_scheme, R.color.validation_surface_error, R.color.red, false);
    212                 break;
    213             case INVALID_NUMBER_OF_ARGUMENTS:
    214                 updateStatusCard(R.string.invalid_number_of_arguments, R.color.validation_surface_error, R.color.red, false);
    215                 break;
    216             case MALFORMED_ARGUMENT:
    217                 updateStatusCard(R.string.malformed_argument, R.color.validation_surface_error, R.color.red, false);
    218                 break;
    219             case INSECURE_HTTP_UNSUPPORTED:
    220                 updateStatusCard(R.string.status_insecure_http_unsupported, R.color.validation_surface_neutral, R.color.text_primary, false);
    221                 break;
    222             case INSECURE_HTTP_DISABLED:
    223                 updateStatusCard(R.string.status_insecure_http_disabled, R.color.validation_surface_neutral, R.color.text_primary, false);
    224                 break;
    225             case KEY_DOWNLOAD_FAILED:
    226                 updateStatusCard(R.string.status_key_download_failed, R.color.validation_surface_error, R.color.red, false);
    227                 break;
    228             case KEY_NOT_FOUND:
    229                 updateStatusCard(R.string.status_key_not_found, R.color.validation_surface_info, R.color.colorSecondary, false);
    230                 break;
    231             case DONATION_STATEMENT_DOWNLOAD_FAILED:
    232                 updateStatusCard(R.string.status_donation_statement_download_failed, R.color.validation_surface_error, R.color.red, false);
    233                 break;
    234             case DONATION_STATEMENT_NOT_FOUND:
    235                 updateStatusCard(R.string.status_donation_statement_not_found, R.color.validation_surface_info, R.color.colorSecondary, false);
    236                 break;
    237             case DONATION_STATEMENT_INVALID:
    238                 updateStatusCard(R.string.status_donation_statement_invalid, R.color.validation_surface_error, R.color.red, false);
    239                 break;
    240             case SIGNATURE_INVALID:
    241                 updateStatusCard(R.string.invalid_signature, R.color.validation_surface_error, R.color.red, false);
    242                 break;
    243             case SIGNATURE_VALID:
    244                 updateStatusCard(R.string.valid_signature, R.color.validation_surface_success, R.color.green, true);
    245                 setSummaryValues(year, taxId, totalAmount);
    246                 break;
    247         }
    248     }
    249 
    250     private void updateStatusCard(@StringRes int messageRes,
    251                                   @ColorRes int backgroundColorRes,
    252                                   @ColorRes int textColorRes,
    253                                   boolean showDetails) {
    254         sigStatusView.setText(messageRes);
    255         if (statusCard != null) {
    256             statusCard.setCardBackgroundColor(ContextCompat.getColor(this, backgroundColorRes));
    257         }
    258         sigStatusView.setTextColor(ContextCompat.getColor(this, textColorRes));
    259         if (summaryContainer != null) {
    260             summaryContainer.setVisibility(showDetails ? View.VISIBLE : View.GONE);
    261         }
    262         if (signatureButton != null) {
    263             signatureButton.setVisibility(showDetails ? View.VISIBLE : View.GONE);
    264             signatureButton.setEnabled(showDetails);
    265         }
    266         if (showDetails) {
    267             setSummaryValues(year, taxId, totalAmount);
    268         } else {
    269             clearSummaryValues();
    270         }
    271     }
    272 
    273     private void setSummaryLabels() {
    274         if (hostLabelView != null) {
    275             hostLabelView.setText(R.string.label_host);
    276             hostLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary));
    277         }
    278         if (yearLabelView != null) {
    279             yearLabelView.setText(R.string.label_year);
    280             yearLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary));
    281         }
    282         if (taxLabelView != null) {
    283             taxLabelView.setText(R.string.label_tax_id);
    284             taxLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary));
    285         }
    286         if (amountLabelView != null) {
    287             amountLabelView.setText(R.string.label_amount);
    288             amountLabelView.setTextColor(ContextCompat.getColor(this, R.color.text_secondary));
    289         }
    290         clearSummaryValues();
    291     }
    292 
    293     private void clearSummaryValues() {
    294         if (hostValueView != null) {
    295             hostValueView.setText("");
    296         }
    297         if (yearValueView != null) {
    298             yearValueView.setText("");
    299         }
    300         if (taxValueView != null) {
    301             taxValueView.setText("");
    302         }
    303         if (amountValueView != null) {
    304             amountValueView.setText("");
    305         }
    306     }
    307 
    308     private void setSummaryValues(String yearValue, String taxValue, String amountValue) {
    309         if (hostValueView != null) {
    310             hostValueView.setText(valueOrUnknown(hostDisplay));
    311         }
    312         if (yearValueView != null) {
    313             yearValueView.setText(valueOrUnknown(yearValue));
    314         }
    315         if (taxValueView != null) {
    316             taxValueView.setText(valueOrUnknown(taxValue));
    317         }
    318         if (amountValueView != null) {
    319             amountValueView.setText(valueOrUnknown(amountValue));
    320         }
    321     }
    322 
    323     private void setupBackNavigation() {
    324         View backButton = findViewById(R.id.backButton);
    325         if (backButton != null) {
    326             backButton.setOnClickListener(v -> finish());
    327         }
    328     }
    329 
    330     private void showSignatureDialog() {
    331         String lineBreak = "\n";
    332         String message = getString(R.string.signature_info_salt, valueOrUnknown(salt))
    333                 + lineBreak
    334                 + getString(R.string.signature_info_signature, valueOrUnknown(eddsaSignature))
    335                 + lineBreak
    336                 + getString(R.string.signature_info_public_key, valueOrUnknown(publicKey));
    337         new MaterialAlertDialogBuilder(this)
    338                 .setTitle(R.string.signature_info_title)
    339                 .setMessage(message)
    340                 .setPositiveButton(android.R.string.ok, null)
    341                 .show();
    342     }
    343 
    344     private String valueOrUnknown(String candidate) {
    345         return isEmpty(candidate) ? getString(R.string.value_unknown) : candidate;
    346     }
    347 
    348     public native int ed25519_verify(String year, String totalAmount,
    349                                      String taxId, String salt,
    350                                      String eddsaSignature, String publicKey);
    351 
    352     private Uri resolveUri(Intent intent) {
    353         Uri data = intent.getData();
    354         if (data != null) {
    355             return data;
    356         }
    357         String raw = intent.getStringExtra("QR-String");
    358         if (raw == null) {
    359             return null;
    360         }
    361         return Uri.parse(raw);
    362     }
    363 
    364     private boolean isSupportedScheme(String scheme) {
    365         if (scheme == null) {
    366             return false;
    367         }
    368         String lowered = scheme.toLowerCase(Locale.ROOT);
    369         return "donau".equals(lowered) || "donau+http".equals(lowered);
    370     }
    371 
    372     private void resetParsedFields() {
    373         authorityPathSegments.clear();
    374         year = null;
    375         totalAmount = null;
    376         taxId = null;
    377         hostDisplay = null;
    378         salt = null;
    379         eddsaSignature = null;
    380         publicKey = null;
    381         publicKeys.clear();
    382         networkClient = null;
    383     }
    384 
    385     private SignatureStatus parseDonauUri(Uri uri) {
    386         host = uri.getHost();
    387         port = uri.getPort();
    388         if (isEmpty(host)) {
    389             return SignatureStatus.MALFORMED_ARGUMENT;
    390         }
    391 
    392         List<String> segments = uri.getPathSegments();
    393         if (segments == null) {
    394             return SignatureStatus.INVALID_NUMBER_OF_ARGUMENTS;
    395         }
    396 
    397         authorityPathSegments.clear();
    398 
    399         if (segments.size() < 3) {
    400             return SignatureStatus.INVALID_NUMBER_OF_ARGUMENTS;
    401         }
    402 
    403         int yearIndex = segments.size() - 3;
    404         for (int i = 0; i < yearIndex; i++) {
    405             String segment = segments.get(i);
    406             String trimmed = segment != null ? segment.trim() : null;
    407             if (!isEmpty(trimmed)) {
    408                 authorityPathSegments.add(trimmed);
    409             }
    410         }
    411 
    412         hostDisplay = buildHostDisplay();
    413 
    414         String yearCandidate = segments.get(yearIndex);
    415         if (yearCandidate != null) {
    416             yearCandidate = yearCandidate.trim();
    417         }
    418         if (!isFourDigitYear(yearCandidate)) {
    419             return SignatureStatus.MALFORMED_ARGUMENT;
    420         }
    421 
    422         year = yearCandidate;
    423 
    424         // Tax ID: use exact UTF-8 bytes from percent-decoded segment without trimming
    425         String taxIdCandidate = segments.get(yearIndex + 1);
    426         if (isEmpty(taxIdCandidate)) {
    427             return SignatureStatus.MALFORMED_ARGUMENT;
    428         }
    429         taxId = taxIdCandidate;
    430 
    431         String saltCandidate = segments.get(yearIndex + 2);
    432         if (saltCandidate != null) {
    433             saltCandidate = saltCandidate.trim();
    434         }
    435         if (isEmpty(saltCandidate)) {
    436             return SignatureStatus.MALFORMED_ARGUMENT;
    437         }
    438         salt = saltCandidate;
    439 
    440         String totalParam = uri.getQueryParameter("total");
    441         if (totalParam != null) {
    442             String trimmedTotal = totalParam.trim();
    443             if (trimmedTotal.isEmpty()) {
    444                 return SignatureStatus.MALFORMED_ARGUMENT;
    445             }
    446             totalAmount = trimmedTotal;
    447         } else {
    448             totalAmount = null;
    449         }
    450 
    451         String sigParam = uri.getQueryParameter("sig");
    452         if (sigParam != null) {
    453             eddsaSignature = extractEd25519Signature(sigParam);
    454             if (isEmpty(eddsaSignature)) {
    455                 return SignatureStatus.MALFORMED_ARGUMENT;
    456             }
    457         } else {
    458             eddsaSignature = null;
    459         }
    460 
    461         String publicKeyParam = uri.getQueryParameter("pub");
    462         publicKey = isEmpty(publicKeyParam) ? null : publicKeyParam.trim();
    463 
    464         networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments);
    465 
    466         return null;
    467     }
    468 
    469     private SignatureStatus ensurePublicKeyAvailable() {
    470         boolean insecureScheme = isInsecureScheme();
    471         boolean hasEmbeddedPub = !isEmpty(publicKey);
    472         if (insecureScheme) {
    473             if (!BuildConfig.ALLOW_INSECURE_HTTP) {
    474                 return SignatureStatus.INSECURE_HTTP_UNSUPPORTED;
    475             }
    476             if (!isDeveloperModeEnabled()) {
    477                 return SignatureStatus.INSECURE_HTTP_DISABLED;
    478             }
    479             if (hasEmbeddedPub && isTestingHost()) {
    480                 return null;
    481             }
    482         }
    483 
    484         if (!insecureScheme && hasEmbeddedPub) {
    485             return null;
    486         }
    487 
    488         try {
    489             DonauNetworkClient client = getNetworkClient();
    490             List<String> fetched = client.fetchSigningKeys();
    491             if (fetched == null || fetched.isEmpty()) {
    492                 return SignatureStatus.KEY_NOT_FOUND;
    493             }
    494             publicKeys.clear();
    495             publicKeys.addAll(fetched);
    496             return null;
    497         } catch (HttpStatusException e) {
    498             Log.e(TAG, "Failed to download Donau signing keys, HTTP " + e.getStatusCode(), e);
    499             return SignatureStatus.KEY_DOWNLOAD_FAILED;
    500         } catch (IOException | JSONException e) {
    501             Log.e(TAG, "Failed to download Donau signing keys", e);
    502             return SignatureStatus.KEY_DOWNLOAD_FAILED;
    503         }
    504     }
    505 
    506     private SignatureStatus ensureDonationStatementAvailable() {
    507         boolean needsTotal = isEmpty(totalAmount);
    508         boolean needsSignature = isEmpty(eddsaSignature);
    509         if (!needsTotal && !needsSignature) {
    510             return null;
    511         }
    512         if (isEmpty(taxId) || isEmpty(salt) || isEmpty(year)) {
    513             return SignatureStatus.MALFORMED_ARGUMENT;
    514         }
    515         try {
    516             String donorHash = computeDonorHash(taxId, salt);
    517             DonauNetworkClient client = getNetworkClient();
    518             int donationYear = parseYearOrDefault(year);
    519             DonationStatement statement = client.fetchDonationStatement(donationYear, donorHash);
    520             String statementTotal = statement.total();
    521             String statementSignature = statement.signature();
    522             String statementPublicKey = statement.publicKey();
    523             if (isEmpty(statementTotal) || isEmpty(statementSignature) || isEmpty(statementPublicKey)) {
    524                 Log.e(TAG, "Donation statement response missing required fields");
    525                 return SignatureStatus.DONATION_STATEMENT_INVALID;
    526             }
    527             if (!needsTotal && totalAmount != null && !totalAmount.equals(statementTotal)) {
    528                 Log.e(TAG, "Donation statement total mismatch");
    529                 return SignatureStatus.DONATION_STATEMENT_INVALID;
    530             }
    531             if (!isEmpty(publicKey) && !publicKey.equals(statementPublicKey)) {
    532                 Log.e(TAG, "Donation statement public key mismatch");
    533                 return SignatureStatus.DONATION_STATEMENT_INVALID;
    534             }
    535             String extractedSignature = extractEd25519Signature(statementSignature);
    536             String normalizedSignature;
    537             if (extractedSignature != null) {
    538                 normalizedSignature = extractedSignature;
    539             } else {
    540                 if (statementSignature.contains(":") || statementSignature.contains("=")) {
    541                     Log.e(TAG, "Donation statement signature format invalid");
    542                     return SignatureStatus.DONATION_STATEMENT_INVALID;
    543                 }
    544                 normalizedSignature = statementSignature.trim();
    545             }
    546             if (!needsSignature && eddsaSignature != null && !eddsaSignature.equals(normalizedSignature)) {
    547                 Log.e(TAG, "Donation statement signature mismatch");
    548                 return SignatureStatus.DONATION_STATEMENT_INVALID;
    549             }
    550             totalAmount = statementTotal;
    551             eddsaSignature = normalizedSignature;
    552             publicKey = statementPublicKey;
    553             return null;
    554         } catch (HttpStatusException e) {
    555             if (e.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND) {
    556                 return SignatureStatus.DONATION_STATEMENT_NOT_FOUND;
    557             }
    558             Log.e(TAG, "Donation statement download failed, HTTP " + e.getStatusCode(), e);
    559             return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED;
    560         } catch (IOException | JSONException e) {
    561             Log.e(TAG, "Donation statement download failed", e);
    562             return SignatureStatus.DONATION_STATEMENT_DOWNLOAD_FAILED;
    563         } catch (NoSuchAlgorithmException e) {
    564             Log.e(TAG, "Unable to hash donor identifier", e);
    565             return SignatureStatus.DONATION_STATEMENT_INVALID;
    566         }
    567     }
    568 
    569     private DonauNetworkClient getNetworkClient() {
    570         if (networkClient == null) {
    571             networkClient = new DonauNetworkClient(isInsecureScheme(), host, port, authorityPathSegments);
    572         }
    573         return networkClient;
    574     }
    575 
    576     private int parseYearOrDefault(String value) {
    577         if (isEmpty(value)) {
    578             return Integer.MIN_VALUE;
    579         }
    580         try {
    581             return Integer.parseInt(value);
    582         } catch (NumberFormatException e) {
    583             return Integer.MIN_VALUE;
    584         }
    585     }
    586 
    587     private String computeDonorHash(String taxIdValue, String saltValue) throws NoSuchAlgorithmException {
    588         MessageDigest digest = MessageDigest.getInstance("SHA-512");
    589         // H = SHA-512( UTF-8(taxId) || 0x00 || UTF-8(salt) || 0x00 )
    590         digest.update(taxIdValue.getBytes(StandardCharsets.UTF_8));
    591         digest.update(new byte[]{0});
    592         digest.update(saltValue.getBytes(StandardCharsets.UTF_8));
    593         digest.update(new byte[]{0});
    594         byte[] hash = digest.digest();
    595         return CrockfordBase32.encode(hash);
    596     }
    597 
    598     private String buildHostDisplay() {
    599         if (isEmpty(host)) {
    600             return null;
    601         }
    602         StringBuilder builder = new StringBuilder(host);
    603         if (port != -1) {
    604             builder.append(":").append(port);
    605         }
    606         for (String segment : authorityPathSegments) {
    607             if (!isEmpty(segment)) {
    608                 builder.append("/").append(segment);
    609             }
    610         }
    611         return builder.toString();
    612     }
    613 
    614     private boolean isInsecureScheme() {
    615         return uriScheme != null && "donau+http".equalsIgnoreCase(uriScheme);
    616     }
    617 
    618     private boolean isDeveloperModeEnabled() {
    619         if (!BuildConfig.ENABLE_DEVELOPER_MODE) {
    620             return false;
    621         }
    622         SharedPreferences prefs =
    623                 PreferenceManager.getDefaultSharedPreferences(this);
    624         return prefs.getBoolean(SettingsActivity.KEY_DEVELOPER_MODE, false);
    625     }
    626 
    627     private boolean isTestingHost() {
    628         return host != null && host.equalsIgnoreCase("example.com");
    629     }
    630 
    631     private boolean isFourDigitYear(String value) {
    632         if (value == null || value.length() != 4) {
    633             return false;
    634         }
    635         for (int i = 0; i < value.length(); i++) {
    636             if (!Character.isDigit(value.charAt(i))) {
    637                 return false;
    638             }
    639         }
    640         return true;
    641     }
    642 
    643     private boolean isEmpty(String value) {
    644         return value == null || value.trim().isEmpty();
    645     }
    646 
    647     private String extractEd25519Signature(String raw) {
    648         if (raw == null) {
    649             return null;
    650         }
    651         String trimmed = raw.trim();
    652         int separatorIndex = trimmed.indexOf(':');
    653         if (separatorIndex < 0) {
    654             separatorIndex = trimmed.indexOf('=');
    655         }
    656         if (separatorIndex <= 0 || separatorIndex >= trimmed.length() - 1) {
    657             return null;
    658         }
    659         String algorithm = trimmed.substring(0, separatorIndex).trim();
    660         if (!"ED25519".equalsIgnoreCase(algorithm)) {
    661             return null;
    662         }
    663         String signature = trimmed.substring(separatorIndex + 1).trim();
    664         if (signature.isEmpty()) {
    665             return null;
    666         }
    667         return signature;
    668     }
    669 
    670     private boolean isDigitsOnly(String value) {
    671         if (isEmpty(value)) {
    672             return false;
    673         }
    674         for (int i = 0; i < value.length(); i++) {
    675             if (!Character.isDigit(value.charAt(i))) {
    676                 return false;
    677             }
    678         }
    679         return true;
    680     }
    681 }