DonauNetworkClient.java (8159B)
1 package net.taler.donauverificator.network; 2 3 import android.net.Uri; 4 import android.util.Log; 5 6 import org.json.JSONArray; 7 import org.json.JSONException; 8 import org.json.JSONObject; 9 10 import java.io.BufferedReader; 11 import java.io.IOException; 12 import java.io.InputStream; 13 import java.io.InputStreamReader; 14 import java.net.HttpURLConnection; 15 import java.net.MalformedURLException; 16 import java.net.URL; 17 import java.nio.charset.StandardCharsets; 18 import java.util.ArrayList; 19 import java.util.Collections; 20 import java.util.List; 21 import java.util.zip.GZIPInputStream; 22 import java.util.zip.InflaterInputStream; 23 24 public final class DonauNetworkClient { 25 26 private static final String TAG = "DonauNetworkClient"; 27 private static final int TIMEOUT_MS = 5000; 28 29 private final boolean insecureScheme; 30 private final String host; 31 private final int port; 32 private final List<String> authorityPathSegments; 33 34 public DonauNetworkClient(boolean insecureScheme, String host, int port, List<String> authorityPathSegments) { 35 this.insecureScheme = insecureScheme; 36 this.host = host; 37 this.port = port; 38 if (authorityPathSegments == null || authorityPathSegments.isEmpty()) { 39 this.authorityPathSegments = Collections.emptyList(); 40 } else { 41 this.authorityPathSegments = Collections.unmodifiableList(new ArrayList<>(authorityPathSegments)); 42 } 43 } 44 45 public List<String> fetchSigningKeys() throws IOException, JSONException { 46 URL url = buildUrl("keys"); 47 HttpURLConnection connection = openGetConnection(url); 48 connection.setRequestProperty("Accept", "application/json"); 49 try { 50 int status = connection.getResponseCode(); 51 if (status != HttpURLConnection.HTTP_OK) { 52 throw new HttpStatusException(status, "Unexpected status for /keys: " + status); 53 } 54 String body = readStream(connection); 55 JSONObject json = new JSONObject(body); 56 JSONArray signkeys = json.optJSONArray("signkeys"); 57 if (signkeys == null) { 58 return Collections.emptyList(); 59 } 60 List<String> result = new ArrayList<>(); 61 for (int i = 0; i < signkeys.length(); i++) { 62 Object entry = signkeys.get(i); 63 String keyCandidate = extractSigningKey(entry); 64 if (keyCandidate != null) { 65 result.add(keyCandidate); 66 } 67 } 68 return result; 69 } finally { 70 connection.disconnect(); 71 } 72 } 73 74 public DonationStatement fetchDonationStatement(int year, String hashedDonorId) throws IOException, JSONException { 75 URL url = buildUrl("donation-statement", String.valueOf(year), hashedDonorId); 76 HttpURLConnection connection = openGetConnection(url); 77 connection.setRequestProperty("Accept", "application/json"); 78 try { 79 int status = connection.getResponseCode(); 80 if (status != HttpURLConnection.HTTP_OK) { 81 throw new HttpStatusException(status, "Unexpected status for /donation-statement: " + status); 82 } 83 String body = readStream(connection); 84 JSONObject json = new JSONObject(body); 85 String total = trimToNull(json.optString("total", null)); 86 String signature = trimToNull(json.optString("donation_statement_sig", null)); 87 String pub = trimToNull(json.optString("donau_pub", null)); 88 if (total == null || signature == null || pub == null) { 89 throw new JSONException("Incomplete donation statement response"); 90 } 91 return new DonationStatement(total, signature, pub); 92 } finally { 93 connection.disconnect(); 94 } 95 } 96 97 private HttpURLConnection openGetConnection(URL url) throws IOException { 98 HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 99 connection.setRequestMethod("GET"); 100 connection.setConnectTimeout(TIMEOUT_MS); 101 connection.setReadTimeout(TIMEOUT_MS); 102 connection.setInstanceFollowRedirects(false); 103 connection.setRequestProperty("Accept-Encoding", "gzip, deflate"); 104 return connection; 105 } 106 107 private URL buildUrl(String... extraSegments) throws MalformedURLException { 108 if (host == null || host.isEmpty()) { 109 throw new MalformedURLException("Missing host"); 110 } 111 Uri.Builder builder = new Uri.Builder() 112 .scheme(insecureScheme ? "http" : "https") 113 .encodedAuthority(port != -1 ? host + ":" + port : host); 114 for (String segment : authorityPathSegments) { 115 if (segment != null && !segment.trim().isEmpty()) { 116 builder.appendPath(segment.trim()); 117 } 118 } 119 for (String segment : extraSegments) { 120 builder.appendPath(segment); 121 } 122 return new URL(builder.build().toString()); 123 } 124 125 private String readStream(HttpURLConnection connection) throws IOException { 126 InputStream input = null; 127 try { 128 input = maybeWrap(connection.getInputStream(), connection.getHeaderField("Content-Encoding")); 129 BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); 130 StringBuilder builder = new StringBuilder(); 131 String line; 132 while ((line = reader.readLine()) != null) { 133 builder.append(line); 134 } 135 return builder.toString(); 136 } finally { 137 if (input != null) { 138 try { 139 input.close(); 140 } catch (IOException e) { 141 Log.w(TAG, "Failed to close input stream", e); 142 } 143 } 144 } 145 } 146 147 private InputStream maybeWrap(InputStream input, String encoding) throws IOException { 148 if (encoding == null) { 149 return input; 150 } 151 if ("gzip".equalsIgnoreCase(encoding)) { 152 return new GZIPInputStream(input); 153 } 154 if ("deflate".equalsIgnoreCase(encoding)) { 155 return new InflaterInputStream(input); 156 } 157 return input; 158 } 159 160 private static String extractSigningKey(Object entry) { 161 if (entry instanceof JSONObject) { 162 JSONObject object = (JSONObject) entry; 163 String direct = trimToNull(object.optString("key", null)); 164 if (direct != null) { 165 return direct; 166 } 167 JSONObject keyObject = object.optJSONObject("key"); 168 if (keyObject != null) { 169 String nested = trimToNull(keyObject.optString("eddsa_pub", null)); 170 if (nested != null) { 171 return nested; 172 } 173 } 174 return trimToNull(object.optString("eddsa_pub", null)); 175 } 176 if (entry instanceof String) { 177 return trimToNull((String) entry); 178 } 179 return null; 180 } 181 182 private static int extractYear(Object entry) { 183 if (entry instanceof JSONObject) { 184 JSONObject object = (JSONObject) entry; 185 if (object.has("year")) { 186 try { 187 return object.getInt("year"); 188 } catch (JSONException e) { 189 return Integer.MIN_VALUE; 190 } 191 } 192 } 193 return Integer.MIN_VALUE; 194 } 195 196 private static String trimToNull(String value) { 197 if (value == null) { 198 return null; 199 } 200 String trimmed = value.trim(); 201 return trimmed.isEmpty() ? null : trimmed; 202 } 203 204 public record DonationStatement(String total, String signature, String publicKey) { 205 } 206 207 public static final class HttpStatusException extends IOException { 208 private final int statusCode; 209 210 public HttpStatusException(int statusCode, String message) { 211 super(message); 212 this.statusCode = statusCode; 213 } 214 215 public int getStatusCode() { 216 return statusCode; 217 } 218 } 219 }