commit 01f49852ab7eb025f11edd2d76879f207be15aca Author: xocko Date: Thu Oct 15 16:35:05 2026 +0200 Import GingerCal 1.0 source diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b562d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +out/ +bridge/certs/ +.DS_Store +._* +*.apk diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..22d3dc5 --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc87e49 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# GingerCal — Nextcloud CalDAV for Android 2.3 (Galaxy Mini) + +A small native calendar client for Gingerbread (API 9/10). Built for a Samsung Galaxy Mini +(GT-S5570, 240×320, Android 2.3.5) talking to Nextcloud, reached through Pangolin. + +**What it does** + +- Agenda of upcoming events grouped by day, colour-coded per calendar, cached offline (SQLite) +- Create, edit and delete events (title, all-day, start/end, location, notes, alarm) +- Repeating events are shown correctly (server-side expansion, with a client-side fallback); + they can be deleted on the phone but edited only in Nextcloud +- Pick which calendars to show and which one new events go to +- Background sync every 1–24 h, plus phone notifications N minutes before timed events +- Certificate pinning, so a self-signed "legacy" endpoint is still safe against interception + +It does not feed the stock Samsung calendar: 2.3 has no public calendar-provider API for that. + +## Why HTTPS breaks on 2.3, and the fix + +Three separate problems, all fatal for a normal Pangolin/Traefik URL: + +| Problem | Gingerbread | Pangolin (Traefik) | +|---|---|---| +| TLS version | TLS 1.0 max | TLS 1.2 minimum | +| Root CA | no ISRG Root X1 (Let's Encrypt) | Let's Encrypt certificates | +| SNI | unreliable in Apache HttpClient | routes HTTPS by SNI | + +Lowering Traefik's TLS settings would weaken every site you host and still leave the CA problem. +Instead, put a tiny **legacy-TLS bridge** next to Nextcloud and expose it with a Pangolin +**raw TCP resource** (port-based, so no SNI needed). The bridge speaks TLS 1.0 with a +self-signed certificate that the app pins by fingerprint, and forwards **only** `/remote.php/dav/` +to Nextcloud. + +``` +Galaxy Mini --TLS 1.0, pinned cert--> Pangolin VPS :8443 (raw TCP, WireGuard tunnel) + --> newt --> caldav-bridge (nginx) --HTTP--> Nextcloud (CalDAV only) +``` + +### Option A: bridge through Pangolin (works anywhere) + +1. **Bridge** (on the Nextcloud host, in `bridge/`): + - Edit `nginx.conf`: the `server nextcloud:80;` upstream and `set $nc_host` (a name in + Nextcloud's `trusted_domains`). If Nextcloud forces HTTPS, also switch to `proxy_pass https://` + (see "Forced HTTPS / HSTS" below). + - Edit `docker-compose.yml`: the Docker network your Nextcloud container is on. + - `./make-cert.sh` creates the certificate and prints its SHA-256 fingerprint. Note it. + - `docker compose up -d` +2. **Pangolin server** (VPS): + - `config/config.yml`: `flags: allow_raw_resources: true` + - `docker-compose.yml`, `gerbil` service: add port `8443:8443` + - `config/traefik/traefik_config.yml`, `entryPoints`: add + `tcp-8443: { address: ":8443/tcp" }` (the `tcp-` name is required) + - Open TCP 8443 in the VPS firewall, then `docker compose down && docker compose up -d` +3. **Pangolin dashboard**: Resources, Add Resource, **Raw TCP/UDP**, TCP, public port **8443**, + target = the bridge as seen from your newt site (`caldav-bridge:8443` if newt shares its Docker + network, otherwise `127.0.0.1:8443` with the port published). Leave Proxy Protocol off. +4. **Phone**: server address `https://:8443`. On the first test + the app shows the certificate fingerprint; tap **Trust** only if it matches step 1. + +Pangolin's SSO does not apply to raw TCP resources; protection here is Nextcloud's login (use an +app password) plus the pinned certificate. The web UI isn't reachable through the bridge. + +Optional, to let Nextcloud's brute-force protection see real client IPs: tick **Enable Proxy +Protocol** on the resource, add the `pp-transport-v1/v2` block to Pangolin's +`dynamic_config.yml` (see the Pangolin raw-resources docs), and in `nginx.conf` use +`listen 8443 ssl proxy_protocol;`, `set_real_ip_from ;`, +`real_ip_header proxy_protocol;`. Enable both sides together or every connection fails. + +### Option B: home Wi-Fi only (no Pangolin) + +Use `http://` as the server address. Nothing to set up, but it syncs only at +home and the password crosses your LAN unencrypted. The app warns if `http://` points at a +non-LAN address. + +Avoid a plain-HTTP Pangolin resource: it would send your password in clear over the internet. + +### Forced HTTPS / HSTS + +**HSTS itself doesn't matter to the app.** `Strict-Transport-Security` is enforced by browsers; +the app's HTTP client ignores it, and a Pangolin raw TCP resource passes bytes through without +adding headers. The bridge strips the header anyway, so a modern browser that you point at +the bridge (e.g. while debugging) doesn't lock itself to the self-signed certificate. + +**What does matter is a Nextcloud that redirects plain HTTP to its public `https://` URL** +(web server rule, `overwriteprotocol`, `overwrite.cli.url`). Symptoms in the app: + +| App says | Cause | Fix | +|---|---|---| +| "Redirect loop … switch the bridge to proxy_pass https://" | bridge talks HTTP to a Nextcloud that redirects HTTP→HTTPS | in `nginx.conf`: `server nextcloud:443;` and `proxy_pass https://nextcloud;` | +| "The server redirected http:// to https://… (forces HTTPS / HSTS)" | Option B: phone talks `http://` to that Nextcloud on the LAN | use the bridge address, or let the LAN vhost serve `/remote.php/dav/` without the redirect | +| "The server redirected to https://cloud…, not the address set in the app" | an absolute redirect wasn't rewritten | keep the `proxy_redirect` and `absolute_redirect off` lines in `nginx.conf` | + +With `proxy_pass https://` only the bridge-to-Nextcloud hop uses HTTPS (modern TLS, any +certificate, since it's your own server). The phone side stays TLS 1.0 with the pinned +certificate. If the bridge can't reach Nextcloud directly, `proxy_pass` can also point at the +public Pangolin URL. In that case Pangolin authentication must let `/remote.php/dav/` through, and +you should turn `proxy_ssl_verify` on. + +## Install on the phone + +1. Settings, Applications, tick **Unknown sources**. +2. Copy `GingerCal.apk` to the SD card (USB) and open it with the file manager, or download it + over plain `http://` (the 2.3 browser can't open most HTTPS sites either). +3. Open GingerCal, Menu, Settings. In Nextcloud create an app password: Settings, Security, + Devices & sessions. Fill in server, user, app password, then **Save and test connection**. + +Menu key: Sync, New event, Calendars, Settings. Tap an event for details, Edit and Delete. + +## Building + +Needs JDK 11+, Python 3 with Pillow, and `d8` (Android SDK build-tools). No Gradle, no aapt: +`tools/mkapk.py` writes the binary manifest and resource table itself. + +```sh +./build.sh # uses ./stubs to compile +ANDROID_JAR=$SDK/platforms/android-10/android.jar ./build.sh # or the real platform jar +``` + +`gingercal.keystore` (password `android`) is the key the supplied APK was signed with; keep +building with it so updates install over the existing app. A different key means uninstalling +first. Signing uses SHA-1 because Android before 4.3 rejects anything else. + +Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xose`/`secret`): + +```sh +javac -d out src/org/gingercal/*.java test/org/gingercal/*.java +java -cp out org.gingercal.CoreTest # discovery, sync, recurrence, create/edit/delete +java -cp out org.gingercal.RedirectTest # forced-HTTPS / redirect-loop handling (no server needed) +``` + +`tools/check_api.py` verifies every framework call in the dex against published Android API +declarations. + +## Layout + +- `src/` CalDAV core, platform-neutral: discovery, PROPFIND/REPORT, iCalendar, sync, pinning +- `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport +- `stubs/` compile-only Android API 10 signatures (never packaged) +- `bridge/` nginx legacy-TLS bridge: config, compose file, certificate script +- `tools/` APK packer, icon, zipalign, d8 workaround, API checker + +## Security notes + +TLS 1.0 with AES-CBC is old but, combined with a pinned certificate, still keeps the connection +private and authenticated against anyone without the bridge's private key. The bridge exposes +only the DAV endpoint. Use an app password so you can revoke the phone from Nextcloud. If the +phone ever reports that the certificate changed and you didn't regenerate it, don't trust it. diff --git a/android/org/gingercal/AlarmReceiver.java b/android/org/gingercal/AlarmReceiver.java new file mode 100644 index 0000000..f3bded0 --- /dev/null +++ b/android/org/gingercal/AlarmReceiver.java @@ -0,0 +1,41 @@ +package org.gingercal; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.PowerManager; + +/** Boot, periodic-sync and reminder alarms all land here. */ +public class AlarmReceiver extends BroadcastReceiver { + public static final String SYNC = "org.gingercal.SYNC"; + public static final String REMIND = "org.gingercal.REMIND"; + private static PowerManager.WakeLock lock; + + @Override + public void onReceive(Context c, Intent i) { + String a = i.getAction(); + if (REMIND.equals(a)) { + Reminders.fire(c); + } else if (SYNC.equals(a)) { + acquire(c); + App.requestSync(c, false, false); + } else { + // BOOT_COMPLETED / package replaced: alarms don't survive either + App.scheduleSync(c); + Reminders.schedule(c); + } + } + + static synchronized void acquire(Context c) { + if (lock == null) { + PowerManager pm = (PowerManager) c.getApplicationContext().getSystemService("power"); + lock = pm.newWakeLock(1 /* PARTIAL_WAKE_LOCK */, "GingerCal sync"); + lock.setReferenceCounted(false); + } + lock.acquire(5 * 60 * 1000L); + } + + static synchronized void release() { + if (lock != null && lock.isHeld()) lock.release(); + } +} diff --git a/android/org/gingercal/ApacheHttp.java b/android/org/gingercal/ApacheHttp.java new file mode 100644 index 0000000..1160994 --- /dev/null +++ b/android/org/gingercal/ApacheHttp.java @@ -0,0 +1,177 @@ +package org.gingercal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.security.GeneralSecurityException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.conn.scheme.LayeredSocketFactory; +import org.apache.http.conn.scheme.PlainSocketFactory; +import org.apache.http.conn.scheme.Scheme; +import org.apache.http.conn.scheme.SchemeRegistry; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; +import org.apache.http.params.HttpProtocolParams; + +/** + * Android 2.3 transport. HttpURLConnection refuses PROPFIND/REPORT there, so this uses the + * bundled Apache HttpClient, plus a TLS layer that supports certificate pinning (needed for a + * self-signed legacy-TLS bridge) and SNI where the platform offers it. + */ +public class ApacheHttp implements Http { + final DefaultHttpClient client; + final Tls.PinTrust trust; + + public ApacheHttp(String pin, int timeoutMs) throws GeneralSecurityException { + trust = new Tls.PinTrust(pin); + HttpParams p = new BasicHttpParams(); + HttpConnectionParams.setConnectionTimeout(p, timeoutMs); + HttpConnectionParams.setSoTimeout(p, timeoutMs); + HttpConnectionParams.setStaleCheckingEnabled(p, true); + HttpProtocolParams.setUseExpectContinue(p, false); + HttpProtocolParams.setUserAgent(p, "GingerCal/1.0 (Android 2.3)"); + p.setBooleanParameter("http.protocol.handle-redirects", false); + SchemeRegistry reg = new SchemeRegistry(); + reg.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); + reg.register(new Scheme("https", new TlsFactory(trust), 443)); + client = new DefaultHttpClient(new ThreadSafeClientConnManager(p, reg), p); + } + + static class Req extends HttpEntityEnclosingRequestBase { + final String m; + + Req(String m, URI u) { + this.m = m; + setURI(u); + } + + @Override + public String getMethod() { + return m; + } + } + + public Resp exec(String method, String url, String[] headers, String body, String contentType) + throws IOException { + Req r = new Req(method, URI.create(url)); + for (int i = 0; i + 1 < headers.length; i += 2) r.addHeader(headers[i], headers[i + 1]); + if (body != null) { + StringEntity e = new StringEntity(body, "UTF-8"); + e.setContentType(contentType); + r.setEntity(e); + } + trust.reset(); + HttpResponse res; + try { + res = client.execute(r); + } catch (IOException e) { + throw trust.translate(e); + } + Resp out = new Resp(); + out.code = res.getStatusLine().getStatusCode(); + Header h = res.getFirstHeader("ETag"); + if (h != null) out.etag = h.getValue(); + h = res.getFirstHeader("Location"); + if (h != null) out.location = h.getValue(); + HttpEntity en = res.getEntity(); + if (en != null) { + InputStream in = en.getContent(); + try { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) bo.write(buf, 0, n); + out.body = new String(bo.toByteArray(), "UTF-8"); + } finally { + en.consumeContent(); + } + } + return out; + } + + public void close() { + client.getConnectionManager().shutdown(); + } + + // ------------------------------------------------------------------ TLS + + static class TlsFactory implements LayeredSocketFactory { + final SSLSocketFactory f; + final Tls.PinTrust trust; + + TlsFactory(Tls.PinTrust trust) throws GeneralSecurityException { + this.trust = trust; + SSLContext c = SSLContext.getInstance("TLS"); + c.init(null, new TrustManager[] {trust}, null); + f = c.getSocketFactory(); + } + + public Socket createSocket() throws IOException { + return f.createSocket(); + } + + public Socket connectSocket(Socket sock, String host, int port, InetAddress local, int localPort, + HttpParams params) throws IOException { + SSLSocket s = (SSLSocket) (sock != null ? sock : createSocket()); + if (local != null || localPort > 0) { + s.bind(new InetSocketAddress(local, localPort < 0 ? 0 : localPort)); + } + sni(s, host); + s.connect(new InetSocketAddress(host, port), HttpConnectionParams.getConnectionTimeout(params)); + s.setSoTimeout(HttpConnectionParams.getSoTimeout(params)); + s.startHandshake(); + verifyHost(s, host); + return s; + } + + public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { + SSLSocket s = (SSLSocket) f.createSocket(socket, host, port, autoClose); + sni(s, host); + s.startHandshake(); + verifyHost(s, host); + return s; + } + + public boolean isSecure(Socket sock) { + return true; + } + + void verifyHost(SSLSocket s, String host) throws IOException { + if (trust.pinned()) return; // the pin identifies the exact certificate + if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(host, s.getSession())) { + s.close(); + throw new SSLException("Certificate is not valid for " + host); + } + } + + /** Gingerbread's OpenSSLSocketImpl has a hidden setHostname() for SNI. */ + static void sni(SSLSocket s, String host) { + if (host.matches("[0-9.]+") || host.indexOf(':') >= 0) return; + try { + Method m = s.getClass().getMethod("setHostname", String.class); + m.invoke(s, host); + } catch (Throwable ignored) { + } + } + } +} diff --git a/android/org/gingercal/App.java b/android/org/gingercal/App.java new file mode 100644 index 0000000..3b9fd9b --- /dev/null +++ b/android/org/gingercal/App.java @@ -0,0 +1,233 @@ +package org.gingercal; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.UnknownHostException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; + +import javax.net.ssl.SSLException; + +import android.app.Activity; +import android.app.AlarmManager; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import org.gingercal.Model.Ev; + +/** Shared helpers: settings, formatting, scheduling, dialogs. */ +public final class App { + private App() {} + + public static final int[] INTERVALS = {0, 1, 3, 6, 12, 24}; + public static final int[] ALARMS = {-1, 0, 5, 10, 15, 30, 60, 120, 1440}; + + public static SharedPreferences prefs(Context c) { + return c.getSharedPreferences("gingercal", 0); + } + + public static String get(Context c, String k) { + return prefs(c).getString(k, ""); + } + + public static boolean configured(Context c) { + return get(c, "url").length() > 0 && get(c, "user").length() > 0; + } + + public static int interval(Context c) { + return prefs(c).getInt("interval", 3); + } + + public static int remind(Context c) { + return prefs(c).getInt("remind", 15); + } + + public static int ahead(Context c) { + return prefs(c).getInt("ahead", 60); + } + + public static Dav dav(Context c) throws Exception { + return dav(get(c, "url"), get(c, "user"), get(c, "pass"), get(c, "pin")); + } + + public static Dav dav(String url, String user, String pass, String pin) throws Exception { + return new Dav(new ApacheHttp(pin, 30000), url, user, pass); + } + + public static boolean online(Context c) { + ConnectivityManager cm = (ConnectivityManager) c.getSystemService("connectivity"); + NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo(); + return ni != null && ni.isConnected(); + } + + public static void requestSync(Context c, boolean manual, boolean force) { + Intent i = new Intent(c, SyncService.class); + i.putExtra("manual", manual); + i.putExtra("force", force); + c.startService(i); + } + + /** (Re)arms the periodic background sync. */ + public static void scheduleSync(Context c) { + AlarmManager am = (AlarmManager) c.getSystemService("alarm"); + Intent i = new Intent(c, AlarmReceiver.class); + i.setAction(AlarmReceiver.SYNC); + PendingIntent pi = PendingIntent.getBroadcast(c, 1, i, 0); + am.cancel(pi); + int h = interval(c); + if (h > 0 && configured(c)) { + long every = h * ICal.HOUR; + am.setInexactRepeating(1 /* RTC: don't wake the phone just to sync */, + System.currentTimeMillis() + every, every, pi); + } + } + + public static boolean isLan(String url) { + try { + String h = URI.create(Dav.normalize(url)).getHost(); + if (h == null) return false; + h = h.toLowerCase(Locale.US); + if (h.startsWith("10.") || h.startsWith("192.168.") || h.equals("localhost") || h.endsWith(".local") + || h.endsWith(".lan") || h.endsWith(".home.arpa")) return true; + if (h.startsWith("172.")) { + String[] p = h.split("\\."); + int b = Integer.parseInt(p[1]); + return b >= 16 && b <= 31; + } + if (h.startsWith("100.")) { // CGNAT / Tailscale-style ranges + int b = Integer.parseInt(h.split("\\.")[1]); + return b >= 64 && b <= 127; + } + } catch (RuntimeException ignored) { + } + return false; + } + + // ------------------------------------------------------------ messages + + public static String describe(Throwable t) { + if (t instanceof DavException || t instanceof DavException.UntrustedCert) return t.getMessage(); + if (t instanceof UnknownHostException) { + return "Can't resolve " + t.getMessage() + ". No connection, or a typo in the server address."; + } + if (t instanceof ConnectException) return "Can't connect to the server (" + t.getMessage() + "). Wrong port, or the bridge is down?"; + if (t instanceof SocketTimeoutException) return "The server took too long to answer."; + if (t instanceof SSLException) return "TLS error: " + t.getMessage(); + if (t instanceof IOException) return "Network error: " + t; + if (t instanceof OutOfMemoryError) return "Out of memory. Lower 'Days ahead' in Settings."; + return "Error: " + t; + } + + public static void toast(Context c, String s) { + Toast.makeText(c, s, 1).show(); + } + + public interface Trusted { + void trusted(String fingerprint); + } + + public static void askTrust(Activity a, final DavException.UntrustedCert u, final Trusted cb) { + String msg = (u.pinMismatch + ? "WARNING: the server certificate changed since you trusted it. That happens when you " + + "regenerate the bridge certificate - or when someone is intercepting the connection.\n\n" + : "This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, " + + "or for Let's Encrypt on Android 2.3).\n\n") + + "Subject: " + u.subject + "\nIssuer: " + u.issuer + "\nExpires: " + u.expires + + "\n\nSHA-256:\n" + u.fingerprint + + "\n\nOnly trust it if this matches the fingerprint printed on your server."; + new AlertDialog.Builder(a) + .setTitle(u.pinMismatch ? "Certificate changed!" : "Trust this certificate?") + .setMessage(msg) + .setPositiveButton("Trust", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + cb.trusted(u.fingerprint); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + + // ------------------------------------------------------------ formatting + + public static String time(Context c, long t) { + String p = android.text.format.DateFormat.is24HourFormat(c) ? "HH:mm" : "h:mma"; + return new SimpleDateFormat(p, Locale.getDefault()).format(new Date(t)); + } + + public static String day(long t) { + boolean thisYear = year(t) == year(System.currentTimeMillis()); + return new SimpleDateFormat(thisYear ? "EEE d MMM" : "EEE d MMM yyyy", Locale.getDefault()).format(new Date(t)); + } + + static int year(long t) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(t); + return c.get(Calendar.YEAR); + } + + public static String dayHeader(long dayStart) { + long today = ICal.startOfDay(System.currentTimeMillis()); + if (dayStart == today) return "Today · " + day(dayStart); + if (dayStart == ICal.addDays(today, 1)) return "Tomorrow · " + day(dayStart); + return day(dayStart); + } + + public static String when(Context c, Ev e) { + if (e.allDay) { + long lastDay = ICal.addDays(e.end, -1); + if (lastDay <= e.start) return "All day"; + return "All day, until " + day(lastDay); + } + String s = time(c, e.start); + if (e.end <= e.start) return s; + if (ICal.startOfDay(e.end - 1) != ICal.startOfDay(e.start)) { + return s + " – " + day(e.end) + " " + time(c, e.end); + } + return s + " – " + time(c, e.end); + } + + public static String alarmLabel(int m) { + if (m < 0) return "No reminder"; + if (m == 0) return "At start time"; + if (m < 60) return m + " min before"; + if (m < 1440) return (m / 60) + " h before"; + return (m / 1440) + " day before"; + } + + // --------------------------------------------------------------- views + + public static int dp(Context c, float v) { + return (int) (v * c.getResources().getDisplayMetrics().density + 0.5f); + } + + public static TextView text(Context c, String s, float sp, int color) { + TextView t = new TextView(c); + t.setText(s); + t.setTextSize(sp); + t.setTextColor(color); + return t; + } + + public static LinearLayout vbox(Context c) { + LinearLayout l = new LinearLayout(c); + l.setOrientation(LinearLayout.VERTICAL); + return l; + } + + public static int sysDrawable(Context c, String name) { + return c.getResources().getIdentifier(name, "drawable", "android"); + } +} diff --git a/android/org/gingercal/CalendarsActivity.java b/android/org/gingercal/CalendarsActivity.java new file mode 100644 index 0000000..7307fe9 --- /dev/null +++ b/android/org/gingercal/CalendarsActivity.java @@ -0,0 +1,98 @@ +package org.gingercal; + +import java.util.ArrayList; +import java.util.List; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.ScrollView; + +import org.gingercal.Model.Cal; + +/** Pick which calendars to show and which one new events go to. */ +public class CalendarsActivity extends Activity { + final List cals = new ArrayList(); + final List boxes = new ArrayList(); + Button defBtn; + + @Override + protected void onCreate(Bundle b) { + super.onCreate(b); + setTitle("Calendars"); + cals.addAll(Db.get(this).calendarList()); + LinearLayout f = App.vbox(this); + int p = App.dp(this, 8); + f.setPadding(p, p, p, p); + if (cals.isEmpty()) { + f.addView(App.text(this, "No calendars yet. Set up the account in Settings and sync.", 14, 0xFFFFFFFF)); + } + for (Cal c : cals) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + View bar = new View(this); + bar.setBackgroundColor(c.color); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(App.dp(this, 6), -1); + lp.setMargins(0, App.dp(this, 6), App.dp(this, 4), App.dp(this, 6)); + row.addView(bar, lp); + CheckBox cb = new CheckBox(this); + cb.setText(c.name + (c.writable ? "" : " (read-only)")); + cb.setChecked(c.enabled); + row.addView(cb, new LinearLayout.LayoutParams(-1, -2)); + boxes.add(cb); + f.addView(row, new LinearLayout.LayoutParams(-1, -2)); + } + defBtn = new Button(this); + defBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickDefault(); + } + }); + if (!cals.isEmpty()) f.addView(defBtn, new LinearLayout.LayoutParams(-1, -2)); + ScrollView sv = new ScrollView(this); + sv.addView(f); + setContentView(sv); + label(); + } + + void label() { + String def = App.get(this, "defcal"); + String name = "first writable"; + for (Cal c : cals) if (c.href.equals(def)) name = c.name; + defBtn.setText("New events go to: " + name); + } + + void pickDefault() { + final List w = new ArrayList(); + for (Cal c : cals) if (c.writable) w.add(c); + String[] names = new String[w.size()]; + for (int i = 0; i < names.length; i++) names[i] = w.get(i).name; + new AlertDialog.Builder(this).setTitle("Default calendar") + .setItems(names, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int which) { + App.prefs(CalendarsActivity.this).edit().putString("defcal", w.get(which).href).commit(); + label(); + } + }).show(); + } + + @Override + protected void onPause() { + super.onPause(); + boolean changed = false; + for (int i = 0; i < cals.size(); i++) { + boolean on = boxes.get(i).isChecked(); + if (on != cals.get(i).enabled) { + Db.get(this).setEnabled(cals.get(i).href, on); + cals.get(i).enabled = on; + changed = true; + } + } + if (changed) App.requestSync(this, true, false); + } +} diff --git a/android/org/gingercal/Db.java b/android/org/gingercal/Db.java new file mode 100644 index 0000000..bc11013 --- /dev/null +++ b/android/org/gingercal/Db.java @@ -0,0 +1,193 @@ +package org.gingercal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; + +/** Local cache so the agenda works offline and the phone doesn't refetch on every launch. */ +public class Db extends SQLiteOpenHelper implements Model.Store { + private static Db instance; + + public static synchronized Db get(Context c) { + if (instance == null) instance = new Db(c.getApplicationContext()); + return instance; + } + + private Db(Context c) { + super(c, "gingercal.db", null, 1); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL("CREATE TABLE cals (href TEXT PRIMARY KEY, name TEXT, color INTEGER, enabled INTEGER," + + " writable INTEGER, ctag TEXT, synced INTEGER)"); + db.execSQL("CREATE TABLE events (_id INTEGER PRIMARY KEY AUTOINCREMENT, cal TEXT, href TEXT, etag TEXT," + + " uid TEXT, title TEXT, location TEXT, descr TEXT, dtstart INTEGER, dtend INTEGER," + + " allday INTEGER, recurring INTEGER)"); + db.execSQL("CREATE INDEX ev_start ON events(dtstart)"); + db.execSQL("CREATE INDEX ev_cal ON events(cal)"); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int from, int to) { + db.execSQL("DROP TABLE IF EXISTS events"); + db.execSQL("DROP TABLE IF EXISTS cals"); + onCreate(db); + } + + // ------------------------------------------------------------ calendars + + public synchronized Map calendars() { + Map m = new LinkedHashMap(); + Cursor c = getReadableDatabase().rawQuery( + "SELECT href, name, color, enabled, writable, ctag, synced FROM cals ORDER BY name", null); + try { + while (c.moveToNext()) { + Cal x = new Cal(); + x.href = c.getString(0); + x.name = c.getString(1); + x.color = c.getInt(2); + x.enabled = c.getInt(3) != 0; + x.writable = c.getInt(4) != 0; + x.ctag = c.getString(5); + x.synced = c.getLong(6); + m.put(x.href, x); + } + } finally { + c.close(); + } + return m; + } + + public List calendarList() { + return new ArrayList(calendars().values()); + } + + public synchronized void saveCalendar(Cal c) { + getWritableDatabase().execSQL("INSERT OR REPLACE INTO cals (href, name, color, enabled, writable, ctag, synced)" + + " VALUES (?,?,?,?,?,?,?)", new Object[] {c.href, c.name, Integer.valueOf(c.color), + Integer.valueOf(c.enabled ? 1 : 0), Integer.valueOf(c.writable ? 1 : 0), c.ctag, Long.valueOf(c.synced)}); + } + + public synchronized void setEnabled(String href, boolean on) { + // reset ctag/synced so the next sync refetches it + getWritableDatabase().execSQL("UPDATE cals SET enabled=?, ctag='', synced=0 WHERE href=?", + new Object[] {Integer.valueOf(on ? 1 : 0), href}); + if (!on) getWritableDatabase().execSQL("DELETE FROM events WHERE cal=?", new Object[] {href}); + } + + public synchronized void deleteCalendar(String href) { + SQLiteDatabase db = getWritableDatabase(); + db.execSQL("DELETE FROM events WHERE cal=?", new Object[] {href}); + db.execSQL("DELETE FROM cals WHERE href=?", new Object[] {href}); + } + + public synchronized void forgetSyncState() { + getWritableDatabase().execSQL("UPDATE cals SET ctag='', synced=0"); + } + + public synchronized void clearAll() { + SQLiteDatabase db = getWritableDatabase(); + db.execSQL("DELETE FROM events"); + db.execSQL("DELETE FROM cals"); + } + + // --------------------------------------------------------------- events + + public synchronized void replaceEvents(String cal, List evs) { + SQLiteDatabase db = getWritableDatabase(); + db.beginTransaction(); + try { + db.execSQL("DELETE FROM events WHERE cal=?", new Object[] {cal}); + for (Ev e : evs) { + db.execSQL("INSERT INTO events (cal, href, etag, uid, title, location, descr, dtstart, dtend, allday," + + " recurring) VALUES (?,?,?,?,?,?,?,?,?,?,?)", new Object[] {cal, e.href, e.etag, e.uid, + e.title, e.location, e.descr, Long.valueOf(e.start), Long.valueOf(e.end), + Integer.valueOf(e.allDay ? 1 : 0), Integer.valueOf(e.recurring ? 1 : 0)}); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + static final String EV_COLS = "e._id, e.cal, e.href, e.etag, e.uid, e.title, e.location, e.descr, e.dtstart," + + " e.dtend, e.allday, e.recurring, c.color, c.writable"; + + static Ev read(Cursor c) { + Ev e = new Ev(); + e.id = c.getLong(0); + e.cal = c.getString(1); + e.href = c.getString(2); + e.etag = c.getString(3); + e.uid = c.getString(4); + e.title = c.getString(5); + e.location = c.getString(6); + e.descr = c.getString(7); + e.start = c.getLong(8); + e.end = c.getLong(9); + e.allDay = c.getInt(10) != 0; + e.recurring = c.getInt(11) != 0; + e.color = c.getInt(12); + e.writable = c.getInt(13) != 0; + return e; + } + + List query(String where, String[] args) { + List out = new ArrayList(); + Cursor c = getReadableDatabase().rawQuery("SELECT " + EV_COLS + " FROM events e JOIN cals c ON c.href = e.cal" + + " WHERE c.enabled = 1 AND (" + where + ") ORDER BY e.dtstart, e.allday DESC, e.title", args); + try { + while (c.moveToNext()) out.add(read(c)); + } finally { + c.close(); + } + return out; + } + + /** Everything still running or starting after `from`. */ + public synchronized List agenda(long from, long to) { + return query("e.dtend > ? AND e.dtstart < ? OR (e.dtstart = e.dtend AND e.dtstart >= ? AND e.dtstart < ?)", + new String[] {String.valueOf(from), String.valueOf(to), String.valueOf(from), String.valueOf(to)}); + } + + public synchronized Ev event(long id) { + List l = query("e._id = ?", new String[] {String.valueOf(id)}); + return l.isEmpty() ? null : l.get(0); + } + + /** Timed events starting in (after, until]. */ + public synchronized List startingBetween(long after, long until) { + return query("e.allday = 0 AND e.dtstart > ? AND e.dtstart <= ?", + new String[] {String.valueOf(after), String.valueOf(until)}); + } + + public synchronized long nextStartAfter(long after) { + Cursor c = getReadableDatabase().rawQuery("SELECT MIN(e.dtstart) FROM events e JOIN cals c ON c.href = e.cal" + + " WHERE c.enabled = 1 AND e.allday = 0 AND e.dtstart > ?", new String[] {String.valueOf(after)}); + try { + if (c.moveToNext() && !c.isNull(0)) return c.getLong(0); + return -1; + } finally { + c.close(); + } + } + + public synchronized int count() { + Cursor c = getReadableDatabase().rawQuery("SELECT COUNT(*) FROM events", null); + try { + return c.moveToNext() ? c.getInt(0) : 0; + } finally { + c.close(); + } + } +} diff --git a/android/org/gingercal/EditActivity.java b/android/org/gingercal/EditActivity.java new file mode 100644 index 0000000..dade074 --- /dev/null +++ b/android/org/gingercal/EditActivity.java @@ -0,0 +1,383 @@ +package org.gingercal; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.UUID; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DatePickerDialog; +import android.app.ProgressDialog; +import android.app.TimePickerDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.DatePicker; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.TimePicker; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; + +/** Create or edit a single (non-repeating) event. */ +public class EditActivity extends Activity { + EditText title, location, notes; + CheckBox allDay; + Button startDate, startTime, endDate, endTime, alarmBtn, calBtn, save; + final Calendar start = Calendar.getInstance(), end = Calendar.getInstance(); + int alarm = 15, originalAlarm = -3; + String calHref; + Ev editing; + String ics, etag; + List cals = new ArrayList(); + + @Override + protected void onCreate(Bundle b) { + super.onCreate(b); + long id = getIntent().getLongExtra("id", -1); + if (id >= 0) editing = Db.get(this).event(id); + setTitle(editing == null ? "New event" : "Edit event"); + for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) cals.add(c); + + LinearLayout f = App.vbox(this); + int p = App.dp(this, 8); + f.setPadding(p, p, p, p); + title = field(f, "Title", false); + allDay = new CheckBox(this); + allDay.setText("All day"); + allDay.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + refresh(); + } + }); + f.addView(allDay); + f.addView(label("Starts")); + startDate = new Button(this); + startTime = new Button(this); + f.addView(pair(startDate, startTime)); + f.addView(label("Ends")); + endDate = new Button(this); + endTime = new Button(this); + f.addView(pair(endDate, endTime)); + location = field(f, "Location", false); + notes = field(f, "Notes", true); + alarmBtn = new Button(this); + f.addView(alarmBtn, new LinearLayout.LayoutParams(-1, -2)); + calBtn = new Button(this); + f.addView(calBtn, new LinearLayout.LayoutParams(-1, -2)); + save = new Button(this); + save.setText("Save"); + f.addView(save, new LinearLayout.LayoutParams(-1, -2)); + ScrollView sv = new ScrollView(this); + sv.addView(f); + setContentView(sv); + + startDate.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickDate(start, true); + } + }); + endDate.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickDate(end, false); + } + }); + startTime.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickTime(start, true); + } + }); + endTime.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickTime(end, false); + } + }); + alarmBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickAlarm(); + } + }); + calBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + pickCalendar(); + } + }); + save.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + save(); + } + }); + + if (editing == null) { + long now = System.currentTimeMillis(); + start.setTimeInMillis(now - now % ICal.HOUR + ICal.HOUR); + start.set(Calendar.SECOND, 0); + start.set(Calendar.MILLISECOND, 0); + end.setTimeInMillis(start.getTimeInMillis() + ICal.HOUR); + String def = App.get(this, "defcal"); + calHref = cals.isEmpty() ? null : cals.get(0).href; + for (Cal c : cals) if (c.href.equals(def)) calHref = def; + refresh(); + } else { + calHref = editing.cal; + calBtn.setEnabled(false); + fill(editing.title, editing.location, editing.descr, editing.start, editing.end, editing.allDay); + save.setEnabled(false); + new LoadTask().execute(); + } + } + + EditText field(LinearLayout f, String hint, boolean multi) { + EditText e = new EditText(this); + e.setHint(hint); + if (multi) { + e.setInputType(1 | 0x4000 | 0x20000); // text | cap sentences | multi-line + e.setMinLines(3); + } else { + e.setSingleLine(true); + e.setInputType(1 | 0x4000); + } + f.addView(e, new LinearLayout.LayoutParams(-1, -2)); + return e; + } + + TextView label(String s) { + TextView t = App.text(this, s, 13, 0xFFB0BEC5); + t.setPadding(App.dp(this, 4), App.dp(this, 6), 0, 0); + return t; + } + + LinearLayout pair(Button a, Button b) { + LinearLayout l = new LinearLayout(this); + l.setOrientation(LinearLayout.HORIZONTAL); + l.addView(a, new LinearLayout.LayoutParams(0, -2, 3f)); + l.addView(b, new LinearLayout.LayoutParams(0, -2, 2f)); + return l; + } + + void fill(String t, String loc, String desc, long s, long e, boolean ad) { + title.setText(t); + location.setText(loc); + notes.setText(desc); + allDay.setChecked(ad); + start.setTimeInMillis(s); + // all-day DTEND is exclusive; show the last day instead + end.setTimeInMillis(ad ? Math.max(s, ICal.addDays(e, -1)) : e); + refresh(); + } + + void refresh() { + boolean ad = allDay.isChecked(); + startDate.setText(App.day(start.getTimeInMillis())); + endDate.setText(App.day(end.getTimeInMillis())); + startTime.setText(App.time(this, start.getTimeInMillis())); + endTime.setText(App.time(this, end.getTimeInMillis())); + startTime.setVisibility(ad ? View.GONE : View.VISIBLE); + endTime.setVisibility(ad ? View.GONE : View.VISIBLE); + alarmBtn.setText("Alarm: " + App.alarmLabel(alarm)); + String name = "?"; + for (Cal c : Db.get(this).calendarList()) if (c.href.equals(calHref)) name = c.name; + calBtn.setText("Calendar: " + name); + } + + void pickDate(final Calendar c, final boolean isStart) { + new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() { + public void onDateSet(DatePicker v, int y, int m, int d) { + long dur = end.getTimeInMillis() - start.getTimeInMillis(); + c.set(y, m, d); + if (isStart) end.setTimeInMillis(start.getTimeInMillis() + Math.max(0, dur)); + refresh(); + } + }, c.get(Calendar.YEAR), c.get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH)).show(); + } + + void pickTime(final Calendar c, final boolean isStart) { + new TimePickerDialog(this, new TimePickerDialog.OnTimeSetListener() { + public void onTimeSet(TimePicker v, int h, int m) { + long dur = end.getTimeInMillis() - start.getTimeInMillis(); + c.set(Calendar.HOUR_OF_DAY, h); + c.set(Calendar.MINUTE, m); + if (isStart) end.setTimeInMillis(start.getTimeInMillis() + Math.max(0, dur)); + refresh(); + } + }, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE), + android.text.format.DateFormat.is24HourFormat(this)).show(); + } + + void pickAlarm() { + String[] labels = new String[App.ALARMS.length]; + for (int i = 0; i < labels.length; i++) labels[i] = App.alarmLabel(App.ALARMS[i]); + new AlertDialog.Builder(this).setTitle("Alarm (saved in the event)") + .setItems(labels, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + alarm = App.ALARMS[w]; + refresh(); + } + }).show(); + } + + void pickCalendar() { + String[] names = new String[cals.size()]; + for (int i = 0; i < names.length; i++) names[i] = cals.get(i).name; + new AlertDialog.Builder(this).setTitle("Calendar") + .setItems(names, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + calHref = cals.get(w).href; + refresh(); + } + }).show(); + } + + static String txt(TextView t) { + return t.getText().toString().trim(); + } + + void save() { + boolean ad = allDay.isChecked(); + long s, e; + if (ad) { + s = ICal.startOfDay(start.getTimeInMillis()); + e = ICal.addDays(ICal.startOfDay(end.getTimeInMillis()), 1); + } else { + s = start.getTimeInMillis() - start.getTimeInMillis() % 60000L; + e = end.getTimeInMillis() - end.getTimeInMillis() % 60000L; + } + if (e < s || (ad && e <= s)) { + App.toast(this, "The end is before the start."); + return; + } + if (calHref == null) { + App.toast(this, "No writable calendar. Sync first."); + return; + } + String t = txt(title); + new SaveTask(t.length() > 0 ? t : "(no title)", txt(location), notes.getText().toString().trim(), s, e, ad).execute(); + } + + // ----------------------------------------------------------- network + + class LoadTask extends AsyncTask { + ProgressDialog pd; + + @Override + protected void onPreExecute() { + pd = ProgressDialog.show(EditActivity.this, null, "Loading…"); + } + + @Override + protected Object doInBackground(Void... v) { + Dav d = null; + try { + d = App.dav(EditActivity.this); + return d.get(editing.href); + } catch (Throwable t) { + return t; + } finally { + if (d != null) d.close(); + } + } + + @Override + protected void onPostExecute(Object r) { + try { + pd.dismiss(); + } catch (RuntimeException ignored) { + } + if (r instanceof Throwable) { + fail("Can't load event", App.describe((Throwable) r)); + return; + } + String[] got = (String[]) r; + ics = got[0]; + etag = got[1] != null ? got[1] : editing.etag; + if (ICal.isRecurring(ics)) { + fail("Repeating event", "Repeating events can't be edited on the phone yet. Edit the series in Nextcloud."); + return; + } + List vs = ICal.parse(ics); + if (vs.isEmpty()) { + fail("Can't edit", "The server returned no event data."); + return; + } + ICal.VEvent v = vs.get(0); + alarm = v.alarmMin; + originalAlarm = v.alarmMin; + fill(v.summary, v.location, v.description, v.start, v.end, v.allDay); + save.setEnabled(true); + } + } + + void fail(String t, String msg) { + new AlertDialog.Builder(this).setTitle(t).setMessage(msg) + .setPositiveButton("OK", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + finish(); + } + }).show(); + } + + class SaveTask extends AsyncTask { + final String t, loc, desc; + final long s, e; + final boolean ad; + ProgressDialog pd; + + SaveTask(String t, String loc, String desc, long s, long e, boolean ad) { + this.t = t; + this.loc = loc; + this.desc = desc; + this.s = s; + this.e = e; + this.ad = ad; + } + + @Override + protected void onPreExecute() { + pd = ProgressDialog.show(EditActivity.this, null, "Saving…"); + } + + @Override + protected Throwable doInBackground(Void... v) { + Dav d = null; + try { + d = App.dav(EditActivity.this); + if (editing == null) { + String uid = UUID.randomUUID().toString().toUpperCase(); + d.create(calHref, uid, ICal.newEvent(uid, t, loc, desc, s, e, ad, alarm)); + } else { + int a = alarm == originalAlarm ? -2 : alarm; + d.update(editing.href, etag, ICal.modify(ics, t, loc, desc, s, e, ad, a)); + } + return null; + } catch (Throwable x) { + return x; + } finally { + if (d != null) d.close(); + } + } + + @Override + protected void onPostExecute(Throwable x) { + try { + pd.dismiss(); + } catch (RuntimeException ignored) { + } + if (x != null) { + new AlertDialog.Builder(EditActivity.this).setTitle("Not saved") + .setMessage(App.describe(x)).setPositiveButton("OK", null).show(); + return; + } + if (editing == null) App.prefs(EditActivity.this).edit().putString("defcal", calHref).commit(); + App.requestSync(EditActivity.this, true, false); + App.toast(EditActivity.this, "Saved"); + finish(); + } + } +} diff --git a/android/org/gingercal/MainActivity.java b/android/org/gingercal/MainActivity.java new file mode 100644 index 0000000..ebf75f8 --- /dev/null +++ b/android/org/gingercal/MainActivity.java @@ -0,0 +1,373 @@ +package org.gingercal; + +import java.util.ArrayList; +import java.util.List; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; + +/** Agenda: upcoming events grouped by day. */ +public class MainActivity extends Activity { + static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4; + + TextView status; + ListView list; + final List rows = new ArrayList(); + final Agenda adapter = new Agenda(); + boolean askedSetup; + + final BroadcastReceiver rx = new BroadcastReceiver() { + @Override + public void onReceive(Context c, Intent i) { + String state = i.getStringExtra("state"); + if ("start".equals(state)) { + showStatus(); + return; + } + reload(); + String fp = i.getStringExtra("fp"); + if (fp != null) { + DavException.UntrustedCert u = new DavException.UntrustedCert(fp, i.getStringExtra("subject"), + i.getStringExtra("issuer"), i.getStringExtra("expires"), i.getBooleanExtra("mismatch", false)); + App.askTrust(MainActivity.this, u, new App.Trusted() { + public void trusted(String f) { + App.prefs(MainActivity.this).edit().putString("pin", f).commit(); + App.requestSync(MainActivity.this, true, false); + } + }); + } + } + }; + + @Override + protected void onCreate(Bundle b) { + super.onCreate(b); + setTitle("GingerCal"); + LinearLayout root = App.vbox(this); + status = App.text(this, "", 12, 0xFFAAAAAA); + int p = App.dp(this, 6); + status.setPadding(p * 2, p, p * 2, p); + status.setBackgroundColor(0xFF1C1C1C); + root.addView(status, new LinearLayout.LayoutParams(-1, -2)); + list = new ListView(this); + list.setAdapter(adapter); + list.setOnItemClickListener(new AdapterView.OnItemClickListener() { + public void onItemClick(AdapterView parent, View v, int pos, long id) { + Object o = rows.get(pos); + if (o instanceof Ev) showEvent((Ev) o); + } + }); + root.addView(list, new LinearLayout.LayoutParams(-1, -1)); + setContentView(root); + App.scheduleSync(this); + } + + @Override + protected void onResume() { + super.onResume(); + registerReceiver(rx, new IntentFilter(SyncService.STATE)); + if (!App.configured(this)) { + showStatus(); + if (!askedSetup) { + askedSetup = true; + startActivity(new Intent(this, SettingsActivity.class)); + } + return; + } + reload(); + long last = App.prefs(this).getLong("lastsync", 0); + if (!SyncService.running && System.currentTimeMillis() - last > 15 * ICal.MIN) { + App.requestSync(this, true, false); + } + } + + @Override + protected void onPause() { + super.onPause(); + unregisterReceiver(rx); + } + + void showStatus() { + if (!App.configured(this)) { + status.setText("Not set up yet: press Menu > Settings."); + return; + } + if (SyncService.running) { + status.setText("Syncing…"); + status.setTextColor(0xFFAAAAAA); + return; + } + long last = App.prefs(this).getLong("lastsync", 0); + boolean err = App.prefs(this).getBoolean("error", false); + String s = App.get(this, "status"); + String when = last == 0 ? "never" : App.day(last) + " " + App.time(this, last); + if (err) { + status.setText("Sync failed: " + s + "\nLast good sync: " + when); + status.setTextColor(0xFFFF8A80); + } else { + status.setText("Synced " + when + (s.length() > 0 ? " · " + s : "")); + status.setTextColor(0xFFAAAAAA); + } + } + + void reload() { + rows.clear(); + long today = ICal.startOfDay(System.currentTimeMillis()); + List evs = Db.get(this).agenda(today, ICal.addDays(today, App.ahead(this) + 1)); + long lastDay = -1; + for (Ev e : evs) { + long d = Math.max(ICal.startOfDay(e.start), today); + if (d != lastDay) { + rows.add(Long.valueOf(d)); + lastDay = d; + } + rows.add(e); + } + if (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync."); + adapter.notifyDataSetChanged(); + showStatus(); + } + + // ---------------------------------------------------------------- menu + + @Override + public boolean onCreateOptionsMenu(Menu m) { + icon(m.add(0, M_SYNC, 0, "Sync"), "ic_menu_refresh"); + icon(m.add(0, M_NEW, 1, "New event"), "ic_menu_add"); + icon(m.add(0, M_CALS, 2, "Calendars"), "ic_menu_agenda"); + icon(m.add(0, M_SETTINGS, 3, "Settings"), "ic_menu_preferences"); + return true; + } + + void icon(MenuItem i, String name) { + int id = App.sysDrawable(this, name); + if (id != 0) i.setIcon(id); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case M_SYNC: + if (App.configured(this)) App.requestSync(this, true, false); + else startActivity(new Intent(this, SettingsActivity.class)); + return true; + case M_NEW: + if (writableCalendars().isEmpty()) { + App.toast(this, "Sync first: no writable calendar yet."); + } else { + startActivity(new Intent(this, EditActivity.class)); + } + return true; + case M_CALS: + startActivity(new Intent(this, CalendarsActivity.class)); + return true; + case M_SETTINGS: + startActivity(new Intent(this, SettingsActivity.class)); + return true; + } + return false; + } + + List writableCalendars() { + List out = new ArrayList(); + for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) out.add(c); + return out; + } + + // -------------------------------------------------------------- details + + void showEvent(final Ev e) { + StringBuilder m = new StringBuilder(); + m.append(App.day(e.start)).append("\n").append(App.when(this, e)); + if (e.location.length() > 0) m.append("\n\n").append(e.location); + if (e.descr.length() > 0) m.append("\n\n").append(e.descr); + Cal cal = Db.get(this).calendars().get(e.cal); + if (cal != null) m.append("\n\nCalendar: ").append(cal.name); + if (e.recurring) m.append("\n\nRepeating event: edit the series in Nextcloud. Delete removes the whole series."); + AlertDialog.Builder b = new AlertDialog.Builder(this) + .setTitle(e.title.length() > 0 ? e.title : "(no title)") + .setMessage(m.toString()) + .setNegativeButton("Close", null); + if (e.writable && !e.recurring) { + b.setPositiveButton("Edit", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + Intent i = new Intent(MainActivity.this, EditActivity.class); + i.putExtra("id", e.id); + startActivity(i); + } + }); + } + if (e.writable) { + b.setNeutralButton("Delete", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + confirmDelete(e); + } + }); + } + b.show(); + } + + void confirmDelete(final Ev e) { + new AlertDialog.Builder(this) + .setTitle("Delete event?") + .setMessage(e.recurring ? "This deletes EVERY occurrence of \"" + e.title + "\"." : "\"" + e.title + "\" will be deleted on the server.") + .setPositiveButton("Delete", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + new DeleteTask(e).execute(); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + + class DeleteTask extends AsyncTask { + final Ev e; + ProgressDialog pd; + + DeleteTask(Ev e) { + this.e = e; + } + + @Override + protected void onPreExecute() { + pd = ProgressDialog.show(MainActivity.this, null, "Deleting…"); + } + + @Override + protected Throwable doInBackground(Void... v) { + Dav d = null; + try { + d = App.dav(MainActivity.this); + d.delete(e.href, e.etag); + return null; + } catch (Throwable t) { + return t; + } finally { + if (d != null) d.close(); + } + } + + @Override + protected void onPostExecute(Throwable t) { + try { + pd.dismiss(); + } catch (RuntimeException ignored) { + } + if (t != null) { + new AlertDialog.Builder(MainActivity.this).setTitle("Delete failed") + .setMessage(App.describe(t)).setPositiveButton("OK", null).show(); + return; + } + App.requestSync(MainActivity.this, true, false); + for (int i = rows.size() - 1; i >= 0; i--) { + Object o = rows.get(i); + if (o instanceof Ev && ((Ev) o).href.equals(e.href)) rows.remove(i); + } + adapter.notifyDataSetChanged(); + } + } + + // ------------------------------------------------------------- adapter + + static class Holder { + View bar; + TextView time, title, where; + } + + class Agenda extends BaseAdapter { + public int getCount() { + return rows.size(); + } + + public Object getItem(int p) { + return rows.get(p); + } + + public long getItemId(int p) { + return p; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public int getItemViewType(int p) { + return rows.get(p) instanceof Ev ? 1 : 0; + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int p) { + return rows.get(p) instanceof Ev; + } + + public View getView(int p, View convert, ViewGroup parent) { + Object o = rows.get(p); + Context c = MainActivity.this; + if (!(o instanceof Ev)) { + TextView h = convert instanceof TextView ? (TextView) convert : App.text(c, "", 13, 0xFFFFFFFF); + int pad = App.dp(c, 6); + h.setPadding(pad * 2, pad, pad, pad); + h.setBackgroundColor(0xFF2E3B44); + h.setText(o instanceof Long ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o)); + return h; + } + Ev e = (Ev) o; + Holder hd; + View v = convert; + if (v == null || !(v.getTag() instanceof Holder)) { + hd = new Holder(); + LinearLayout row = new LinearLayout(c); + row.setOrientation(LinearLayout.HORIZONTAL); + hd.bar = new View(c); + row.addView(hd.bar, new LinearLayout.LayoutParams(App.dp(c, 5), -1)); + LinearLayout col = App.vbox(c); + int pad = App.dp(c, 6); + col.setPadding(pad * 2, pad, pad, pad); + hd.time = App.text(c, "", 12, 0xFFB0BEC5); + hd.title = App.text(c, "", 16, 0xFFFFFFFF); + hd.where = App.text(c, "", 12, 0xFF90A4AE); + col.addView(hd.time); + col.addView(hd.title); + col.addView(hd.where); + row.addView(col, new LinearLayout.LayoutParams(-1, -2)); + row.setTag(hd); + v = row; + } else { + hd = (Holder) v.getTag(); + } + hd.bar.setBackgroundColor(e.color); + hd.time.setText(App.when(c, e) + (e.recurring ? " (repeats)" : "")); + hd.title.setText(e.title.length() > 0 ? e.title : "(no title)"); + hd.where.setText(e.location); + hd.where.setVisibility(e.location.length() > 0 ? View.VISIBLE : View.GONE); + return v; + } + } +} diff --git a/android/org/gingercal/Reminders.java b/android/org/gingercal/Reminders.java new file mode 100644 index 0000000..052be75 --- /dev/null +++ b/android/org/gingercal/Reminders.java @@ -0,0 +1,63 @@ +package org.gingercal; + +import java.util.List; + +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import org.gingercal.Model.Ev; + +/** Local notifications before timed events, driven from the cached agenda. */ +public final class Reminders { + private Reminders() {} + + static PendingIntent alarmIntent(Context c) { + Intent i = new Intent(c, AlarmReceiver.class); + i.setAction(AlarmReceiver.REMIND); + return PendingIntent.getBroadcast(c, 2, i, 0x08000000 /* FLAG_UPDATE_CURRENT */); + } + + public static void schedule(Context c) { + AlarmManager am = (AlarmManager) c.getSystemService("alarm"); + PendingIntent pi = alarmIntent(c); + am.cancel(pi); + int m = App.remind(c); + if (m < 0) return; + long now = System.currentTimeMillis(); + long notified = App.prefs(c).getLong("notified", 0); + long next = Db.get(c).nextStartAfter(Math.max(notified, now - 60000L)); + if (next < 0) return; + am.set(0 /* RTC_WAKEUP */, Math.max(now + 1000, next - m * ICal.MIN), pi); + } + + public static void fire(Context c) { + int m = App.remind(c); + if (m < 0) return; + long now = System.currentTimeMillis(); + long notified = App.prefs(c).getLong("notified", 0); + // never nag about things that started more than 30 minutes ago + List due = Db.get(c).startingBetween(Math.max(notified, now - 30 * ICal.MIN), now + m * ICal.MIN + 30000L); + NotificationManager nm = (NotificationManager) c.getSystemService("notification"); + long newest = notified; + int icon = App.sysDrawable(c, "ic_popup_reminder"); + if (icon == 0) icon = App.sysDrawable(c, "ic_dialog_info"); + for (Ev e : due) { + Intent open = new Intent(c, MainActivity.class); + open.setFlags(0x10000000 /* FLAG_ACTIVITY_NEW_TASK */); + PendingIntent pi = PendingIntent.getActivity(c, 0, open, 0); + Notification n = new Notification(icon, e.title, e.start); + String body = App.when(c, e) + (e.location.length() > 0 ? " · " + e.location : ""); + n.setLatestEventInfo(c, e.title.length() > 0 ? e.title : "(no title)", body, pi); + n.flags |= 16; // FLAG_AUTO_CANCEL + n.defaults = -1; // DEFAULT_ALL: sound, vibrate, lights + nm.notify((e.href + e.start).hashCode(), n); + if (e.start > newest) newest = e.start; + } + if (newest != notified) App.prefs(c).edit().putLong("notified", newest).commit(); + schedule(c); + } +} diff --git a/android/org/gingercal/SettingsActivity.java b/android/org/gingercal/SettingsActivity.java new file mode 100644 index 0000000..e44b5d3 --- /dev/null +++ b/android/org/gingercal/SettingsActivity.java @@ -0,0 +1,243 @@ +package org.gingercal; + +import java.util.List; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import org.gingercal.Model.Cal; + +/** Account, TLS pin, sync interval and reminder settings. */ +public class SettingsActivity extends Activity { + EditText url, user, pass, pin, ahead; + Button intervalBtn, remindBtn; + int interval, remind; + + @Override + protected void onCreate(Bundle b) { + super.onCreate(b); + setTitle("GingerCal settings"); + LinearLayout f = App.vbox(this); + int p = App.dp(this, 8); + f.setPadding(p, p, p, p); + f.addView(App.text(this, "Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your " + + "legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home " + + "Wi-Fi. Use an app password, not your real one.", 12, 0xFFB0BEC5)); + url = field(f, "Server address", "https://cloud.example.com:8443", 1 | 0x10 /* URI */); + user = field(f, "User name", "", 1 | 0x80000 /* no suggestions */); + pass = field(f, "App password", "", 1 | 0x80 /* password */); + pin = field(f, "Pinned certificate SHA-256 (filled in for you)", "optional", 1 | 0x80000); + ahead = field(f, "Days ahead to sync", "60", 2 /* number */); + intervalBtn = new Button(this); + f.addView(intervalBtn, new LinearLayout.LayoutParams(-1, -2)); + remindBtn = new Button(this); + f.addView(remindBtn, new LinearLayout.LayoutParams(-1, -2)); + Button test = new Button(this); + test.setText("Save and test connection"); + f.addView(test, new LinearLayout.LayoutParams(-1, -2)); + Button resync = new Button(this); + resync.setText("Re-download everything"); + f.addView(resync, new LinearLayout.LayoutParams(-1, -2)); + ScrollView sv = new ScrollView(this); + sv.addView(f); + setContentView(sv); + + url.setText(App.get(this, "url")); + user.setText(App.get(this, "user")); + pass.setText(App.get(this, "pass")); + pin.setText(App.get(this, "pin")); + ahead.setText(String.valueOf(App.ahead(this))); + interval = App.interval(this); + remind = App.remind(this); + labels(); + + intervalBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + final String[] l = new String[App.INTERVALS.length]; + for (int i = 0; i < l.length; i++) l[i] = intervalLabel(App.INTERVALS[i]); + new AlertDialog.Builder(SettingsActivity.this).setTitle("Background sync") + .setItems(l, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + interval = App.INTERVALS[w]; + labels(); + } + }).show(); + } + }); + remindBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + final int[] opts = {-1, 0, 5, 10, 15, 30, 60}; + String[] l = new String[opts.length]; + for (int i = 0; i < l.length; i++) l[i] = opts[i] < 0 ? "Off" : App.alarmLabel(opts[i]); + new AlertDialog.Builder(SettingsActivity.this).setTitle("Phone notification") + .setItems(l, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + remind = opts[w]; + labels(); + } + }).show(); + } + }); + test.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + confirmAndTest(); + } + }); + resync.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + save(); + Db.get(SettingsActivity.this).forgetSyncState(); + App.requestSync(SettingsActivity.this, true, true); + App.toast(SettingsActivity.this, "Re-downloading in the background"); + finish(); + } + }); + } + + EditText field(LinearLayout f, String label, String hint, int type) { + TextView l = App.text(this, label, 13, 0xFFFFFFFF); + l.setPadding(App.dp(this, 4), App.dp(this, 8), 0, 0); + f.addView(l); + EditText e = new EditText(this); + e.setHint(hint); + e.setSingleLine(true); + e.setInputType(type); + f.addView(e, new LinearLayout.LayoutParams(-1, -2)); + return e; + } + + static String intervalLabel(int h) { + return h == 0 ? "Manual only" : h == 1 ? "Every hour" : "Every " + h + " hours"; + } + + void labels() { + intervalBtn.setText("Background sync: " + intervalLabel(interval)); + remindBtn.setText("Notify: " + (remind < 0 ? "off" : App.alarmLabel(remind))); + } + + static String txt(TextView t) { + return t.getText().toString().trim(); + } + + @Override + protected void onPause() { + super.onPause(); + if (txt(url).length() > 0) save(); + } + + void save() { + String oldAccount = App.get(this, "url") + "|" + App.get(this, "user"); + int days = 60; + try { + days = Math.max(7, Math.min(365, Integer.parseInt(txt(ahead)))); + } catch (NumberFormatException ignored) { + } + String u = txt(url); + if (u.length() > 0) u = Dav.normalize(u); + App.prefs(this).edit() + .putString("url", u) + .putString("user", txt(user)) + .putString("pass", pass.getText().toString()) + .putString("pin", Tls.normPin(txt(pin)).length() == 64 ? txt(pin) : "") + .putInt("ahead", days) + .putInt("interval", interval) + .putInt("remind", remind) + .commit(); + if (!oldAccount.equals(u + "|" + txt(user))) { + Db.get(this).clearAll(); + App.prefs(this).edit().putLong("lastsync", 0).putString("status", "").commit(); + } + App.scheduleSync(this); + Reminders.schedule(this); + } + + void confirmAndTest() { + String u = txt(url); + if (u.length() == 0 || txt(user).length() == 0) { + App.toast(this, "Server address and user name are required."); + return; + } + if (Dav.normalize(u).startsWith("http://") && !App.isLan(u)) { + new AlertDialog.Builder(this).setTitle("Unencrypted connection") + .setMessage("http:// sends your password in clear text over the internet. Use the legacy-TLS " + + "bridge instead, or only use http:// to a LAN address.") + .setPositiveButton("Use anyway", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + save(); + new TestTask().execute(); + } + }) + .setNegativeButton("Cancel", null).show(); + return; + } + save(); + new TestTask().execute(); + } + + class TestTask extends AsyncTask { + ProgressDialog pd; + + @Override + protected void onPreExecute() { + pd = ProgressDialog.show(SettingsActivity.this, null, "Connecting…"); + } + + @Override + protected Object doInBackground(Void... v) { + Dav d = null; + try { + d = App.dav(SettingsActivity.this); + return d.calendars(d.calendarHome()); + } catch (Throwable t) { + return t; + } finally { + if (d != null) d.close(); + } + } + + @SuppressWarnings("unchecked") + @Override + protected void onPostExecute(Object r) { + try { + pd.dismiss(); + } catch (RuntimeException ignored) { + } + if (r instanceof DavException.UntrustedCert) { + App.askTrust(SettingsActivity.this, (DavException.UntrustedCert) r, new App.Trusted() { + public void trusted(String fp) { + pin.setText(fp); + save(); + new TestTask().execute(); + } + }); + return; + } + if (r instanceof Throwable) { + new AlertDialog.Builder(SettingsActivity.this).setTitle("Connection failed") + .setMessage(App.describe((Throwable) r)).setPositiveButton("OK", null).show(); + return; + } + List cals = (List) r; + StringBuilder s = new StringBuilder("Connected. Found " + cals.size() + " calendar(s):\n"); + for (Cal c : cals) s.append("\n• ").append(c.name).append(c.writable ? "" : " (read-only)"); + new AlertDialog.Builder(SettingsActivity.this).setTitle("It works") + .setMessage(s.toString()) + .setPositiveButton("Sync now", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + App.requestSync(SettingsActivity.this, true, false); + finish(); + } + }).show(); + } + } +} diff --git a/android/org/gingercal/SyncService.java b/android/org/gingercal/SyncService.java new file mode 100644 index 0000000..cf1d44f --- /dev/null +++ b/android/org/gingercal/SyncService.java @@ -0,0 +1,64 @@ +package org.gingercal; + +import android.app.IntentService; +import android.content.Intent; +import android.content.SharedPreferences; + +/** Runs a sync on a worker thread and broadcasts the outcome to whatever screen is open. */ +public class SyncService extends IntentService { + public static final String STATE = "org.gingercal.SYNC_STATE"; + public static volatile boolean running; + + public SyncService() { + super("GingerCalSync"); + } + + @Override + protected void onHandleIntent(Intent intent) { + boolean manual = intent != null && intent.getBooleanExtra("manual", false); + boolean force = intent != null && intent.getBooleanExtra("force", false); + SharedPreferences p = App.prefs(this); + Dav dav = null; + try { + if (!App.configured(this)) return; + if (!manual && !App.online(this)) return; + running = true; + send("start", null, null); + dav = App.dav(this); + long now = System.currentTimeMillis(); + Syncer.Result r = Syncer.sync(dav, Db.get(this), now, 1, App.ahead(this), force); + p.edit().putLong("lastsync", now).putString("status", r.summary()).putBoolean("error", false).commit(); + send("done", r.summary(), null); + } catch (DavException.UntrustedCert u) { + p.edit().putString("status", u.getMessage()).putBoolean("error", true).commit(); + send("error", u.getMessage(), u); + } catch (Throwable t) { + String msg = App.describe(t); + p.edit().putString("status", msg).putBoolean("error", true).commit(); + send("error", msg, null); + } finally { + running = false; + if (dav != null) dav.close(); + try { + Reminders.schedule(this); + } catch (RuntimeException ignored) { + } + AlarmReceiver.release(); + } + } + + void send(String state, String msg, DavException.UntrustedCert u) { + Intent b = new Intent(STATE); + b.setPackage(getPackageName()); + b.putExtra("state", state); + if (msg != null) b.putExtra("msg", msg); + if (u != null) { + b.putExtra("fp", u.fingerprint); + b.putExtra("subject", u.subject); + b.putExtra("issuer", u.issuer); + b.putExtra("expires", u.expires); + b.putExtra("mismatch", u.pinMismatch); + } + sendBroadcast(b); + } +} diff --git a/bridge/docker-compose.yml b/bridge/docker-compose.yml new file mode 100644 index 0000000..08ad546 --- /dev/null +++ b/bridge/docker-compose.yml @@ -0,0 +1,23 @@ +# Run this on the machine where Nextcloud and your Pangolin "newt" site connector run. +# Then create a Pangolin Raw TCP resource that targets this container on port 8443 (README.md). +services: + caldav-bridge: + image: nginx:1.27-alpine + container_name: caldav-bridge + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./certs:/etc/nginx/certs:ro + # newt in Docker: put newt on this same network and use target "caldav-bridge:8443"; no + # published port needed. newt installed on the host instead: uncomment and use 127.0.0.1:8443. + # ports: + # - "127.0.0.1:8443:8443" + networks: + - nextcloud + +networks: + nextcloud: + # EDIT: the network your Nextcloud container is on (docker network ls), so that the + # "nextcloud:80" upstream in nginx.conf resolves. + external: true + name: nextcloud_default diff --git a/bridge/make-cert.sh b/bridge/make-cert.sh new file mode 100755 index 0000000..c8c7e27 --- /dev/null +++ b/bridge/make-cert.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Creates the bridge's self-signed certificate (10 years) and prints the SHA-256 +# fingerprint to compare with what the phone shows when you tap "Trust". +set -eu +cd "$(dirname "$0")" +mkdir -p certs +NAME=${1:-caldav-bridge} +if [ ! -f certs/bridge.key ]; then + openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout certs/bridge.key -out certs/bridge.crt -subj "/CN=$NAME" 2>/dev/null + chmod 600 certs/bridge.key + echo "Created certs/bridge.crt" +fi +echo "SHA-256 fingerprint (must match the phone):" +openssl x509 -in certs/bridge.crt -noout -fingerprint -sha256 | cut -d= -f2 diff --git a/bridge/nginx.conf b/bridge/nginx.conf new file mode 100644 index 0000000..ed45c47 --- /dev/null +++ b/bridge/nginx.conf @@ -0,0 +1,75 @@ +# Legacy-TLS CalDAV bridge for Android 2.3 phones. +# +# The phone speaks TLS 1.0 with old ciphers and has no modern root CAs. This server accepts +# exactly that, with a self-signed certificate the app pins by SHA-256 fingerprint, and +# forwards ONLY the CalDAV endpoints to Nextcloud. The Nextcloud web UI stays unreachable here. +# +# Edit the lines marked EDIT, then see README.md. + +upstream nextcloud { + # EDIT: where Nextcloud listens, as seen from this container. + # Nextcloud serves plain HTTP internally (official docker image :80, AIO :11000): keep :80. + # Nextcloud forces HTTPS (port 80 redirects to https://, often alongside HSTS): use its + # HTTPS port, e.g. nextcloud:443, AND switch proxy_pass below to https://. + server nextcloud:80; +} + +server { + # Pangolin raw TCP resource -> this port. Add "proxy_protocol" here only if you also tick + # "Enable Proxy Protocol" on the Pangolin resource (see README). + listen 8443 ssl; + server_name _; + + # EDIT: Nextcloud's own host name, as listed in its trusted_domains. + set $nc_host cloud.example.com; + + ssl_certificate /etc/nginx/certs/bridge.crt; + ssl_certificate_key /etc/nginx/certs/bridge.key; + + # Android 2.3: TLS 1.0, AES-CBC-SHA. SECLEVEL=0 lets OpenSSL 3 negotiate it at all. + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers "ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-SHA:AES256-SHA:@SECLEVEL=0"; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:legacy:1m; + ssl_session_timeout 1h; + + # Relative Location headers: correct even when Pangolin's public port differs from 8443. + absolute_redirect off; + + client_max_body_size 5m; + access_log /var/log/nginx/caldav-bridge.log; + + # HSTS from Nextcloud (or added by your proxy) is meant for its real https site. Passed on + # here it would tell browsers to treat this self-signed endpoint as strict HTTPS, with no + # way to click through. The phone app ignores HSTS either way. + proxy_hide_header Strict-Transport-Security; + + location /remote.php/dav/ { + proxy_pass http://nextcloud; + # EDIT if Nextcloud forces HTTPS: replace the line above with + # proxy_pass https://nextcloud; + # The phone side stays TLS 1.0; this bridge-to-Nextcloud leg then uses modern TLS. + proxy_ssl_server_name on; + proxy_ssl_name $nc_host; + # Internal hop to your own server, often with a self-signed or LAN-only certificate. + proxy_ssl_verify off; + + proxy_set_header Host $nc_host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + proxy_buffering off; + # Nextcloud builds absolute URLs from its public address (overwrite.cli.url / + # overwriteprotocol). Keep the phone on the bridge by reducing them to the path. + proxy_redirect ~^https?://[^/]+(/.*)$ $1; + } + + location = /.well-known/caldav { + return 301 /remote.php/dav/; + } + + location / { + return 404; + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7d100cf --- /dev/null +++ b/build.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle or aapt. +# +# Needs: JDK 11+ (javac, keytool, jarsigner), python3 with Pillow (icon), and d8. +# D8 command that runs d8 (default: "d8" from Android SDK build-tools on PATH) +# ANDROID_JAR optional: a real platforms/android-10/android.jar; otherwise ./stubs is used +# KEYSTORE optional: signing keystore (created on first build; keep it to allow upgrades) +set -eu +cd "$(dirname "$0")" +OUT=build +D8=${D8:-d8} +KS=${KEYSTORE:-gingercal.keystore} + +rm -rf "$OUT" +mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" + +if [ -n "${ANDROID_JAR:-}" ]; then + CP="$ANDROID_JAR" +else + javac -nowarn -Xlint:none --release 8 -d "$OUT/stubs" $(find stubs -name "*.java") 2>/dev/null + CP="$OUT/stubs" +fi + +# Java 8 bytecode, Java 6-era APIs only (Android 2.3 runs a Harmony class library). +javac --release 8 -encoding UTF-8 -Xlint:-options -cp "$CP" -d "$OUT/classes" $(find src android -name '*.java') + +python3 tools/strip_method_params.py $(find "$OUT/classes" -name '*.class') + +# dex format 035 for Dalvik +# (d8 warns about library classes it can't see; harmless since nothing needs desugaring) +$D8 --min-api 9 --release --output "$OUT/dex" $(find "$OUT/classes" -name '*.class') >"$OUT/d8.log" 2>&1 \ + || { cat "$OUT/d8.log"; exit 1; } + +python3 tools/mkicon.py "$OUT/icon.png" +python3 tools/mkapk.py AndroidManifest.xml "$OUT/dex/classes.dex" "$OUT/icon.png" "$OUT/unsigned.apk" + +if [ ! -f "$KS" ]; then + keytool -genkeypair -keystore "$KS" -storetype PKCS12 -storepass android -keypass android \ + -alias gingercal -keyalg RSA -keysize 2048 -sigalg SHA1withRSA -validity 10000 -dname "CN=GingerCal" 2>/dev/null +fi +cp "$OUT/unsigned.apk" "$OUT/signed.apk" +# Android < 4.3 only verifies SHA-1 JAR signatures. +jarsigner -keystore "$KS" -storepass android -digestalg SHA1 -sigalg SHA1withRSA \ + "$OUT/signed.apk" gingercal >/dev/null +python3 tools/zipalign.py "$OUT/signed.apk" GingerCal.apk +ls -l GingerCal.apk diff --git a/gingercal.keystore b/gingercal.keystore new file mode 100644 index 0000000..31cc1de Binary files /dev/null and b/gingercal.keystore differ diff --git a/src/org/gingercal/B64.java b/src/org/gingercal/B64.java new file mode 100644 index 0000000..8f9406a --- /dev/null +++ b/src/org/gingercal/B64.java @@ -0,0 +1,22 @@ +package org.gingercal; + +/** Base64 encoder (android.util.Base64 exists on 2.2+, but this keeps the core portable). */ +public final class B64 { + private static final char[] T = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray(); + + private B64() {} + + public static String encode(byte[] in) { + StringBuilder b = new StringBuilder((in.length + 2) / 3 * 4); + for (int i = 0; i < in.length; i += 3) { + int n = (in[i] & 0xff) << 16; + if (i + 1 < in.length) n |= (in[i + 1] & 0xff) << 8; + if (i + 2 < in.length) n |= in[i + 2] & 0xff; + b.append(T[(n >> 18) & 63]).append(T[(n >> 12) & 63]); + b.append(i + 1 < in.length ? T[(n >> 6) & 63] : '='); + b.append(i + 2 < in.length ? T[n & 63] : '='); + } + return b.toString(); + } +} diff --git a/src/org/gingercal/Dav.java b/src/org/gingercal/Dav.java new file mode 100644 index 0000000..3490646 --- /dev/null +++ b/src/org/gingercal/Dav.java @@ -0,0 +1,386 @@ +package org.gingercal; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; +import org.gingercal.Xml.Node; + +/** CalDAV client: discovery, calendar listing, event query and simple writes. */ +public class Dav { + public static final String D = "DAV:"; + public static final String C = "urn:ietf:params:xml:ns:caldav"; + public static final String CS = "http://calendarserver.org/ns/"; + public static final String A = "http://apple.com/ns/ical/"; + + final Http http; + final String url; + final String auth; + + public Dav(Http http, String serverUrl, String user, String password) { + this.http = http; + this.url = normalize(serverUrl); + try { + this.auth = "Basic " + B64.encode((user + ":" + password).getBytes("UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + public static String normalize(String u) { + u = u.trim(); + if (!u.toLowerCase(Locale.US).startsWith("http://") && !u.toLowerCase(Locale.US).startsWith("https://")) { + u = "https://" + u; + } + while (u.endsWith("/") && u.indexOf('/', 8) < u.length() - 1) u = u.substring(0, u.length() - 1); + return u; + } + + static String origin(String u) { + URI x = URI.create(u); + return x.getScheme() + "://" + x.getRawAuthority(); + } + + /** scheme://host:port with default ports filled in, for comparing redirect targets. */ + static String originKey(String u) { + URI x = URI.create(u); + String s = x.getScheme() == null ? "" : x.getScheme().toLowerCase(Locale.US); + int port = x.getPort(); + if (port < 0) port = s.equals("https") ? 443 : 80; + String host = x.getHost() == null ? "" : x.getHost().toLowerCase(Locale.US); + return s + "://" + host + ":" + port; + } + + public static String abs(String base, String href) { + if (href.startsWith("http://") || href.startsWith("https://")) return href; + return URI.create(base).resolve(href).toString(); + } + + // -------------------------------------------------------------- requests + + public static class Response { + public String href; + public final Map props = new HashMap(); + + public Node prop(String ns, String name) { + return props.get(ns + name); + } + + public String text(String ns, String name) { + Node n = prop(ns, name); + return n == null ? null : n.text(); + } + } + + /** + * Sends a request, following redirects on the same server (Apache won't for PROPFIND/REPORT). + * Redirects elsewhere, typically Nextcloud forcing its public https:// address, can't work + * on Android 2.3, so they fail with an explanation instead of a doomed TLS 1.2 attempt. + */ + Http.Resp send(String method, String target, String depth, String body, String[] extra) + throws IOException { + String u = target; + List visited = new ArrayList(); + for (int hop = 0; hop < 6; hop++) { + visited.add(u); + List h = new ArrayList(); + h.add("Authorization"); + h.add(auth); + if (depth != null) { + h.add("Depth"); + h.add(depth); + } + if (extra != null) for (int i = 0; i < extra.length; i++) h.add(extra[i]); + String ct = body == null ? null + : method.equals("PUT") ? "text/calendar; charset=utf-8" : "application/xml; charset=utf-8"; + Http.Resp r = http.exec(method, u, h.toArray(new String[h.size()]), body, ct); + if ((r.code == 301 || r.code == 302 || r.code == 303 || r.code == 307 || r.code == 308) + && r.location != null) { + String next = abs(u, r.location); + checkRedirect(u, next, visited, r.code); + u = next; + continue; + } + if (r.code == 401) { + throw new DavException(401, "Login refused (401). Check the user name and use a " + + "Nextcloud app password (Settings > Security > Devices & sessions)."); + } + if (r.code == 403 && body != null && method.equals("PUT")) { + throw new DavException(403, "Not allowed to write to this calendar (403). It may be read-only/shared."); + } + return r; + } + throw new DavException(DavException.REDIRECT, "Too many redirects from the server."); + } + + static void checkRedirect(String from, String to, List visited, int code) throws DavException { + String a = originKey(from), b = originKey(to); + if (a.startsWith("http:") && b.startsWith("https:")) { + throw new DavException(DavException.REDIRECT, "The server redirected http:// to " + + origin(to) + " (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: " + + "use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS')."); + } + if (!a.equals(b)) { + throw new DavException(DavException.REDIRECT, "The server redirected to " + origin(to) + + ", not the address set in the app. Nextcloud is sending its public URL; check the " + + "bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS')."); + } + if (visited.contains(to)) { + throw new DavException(DavException.REDIRECT, "Redirect loop (HTTP " + code + " back to the same " + + "address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the " + + "bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS')."); + } + } + + List multistatus(String method, String target, String depth, String body) throws IOException { + Http.Resp r = send(method, target, depth, body, null); + if (r.code != 207) { + throw new DavException(r.code, method + " " + URI.create(target).getPath() + " failed: HTTP " + r.code + + (r.code == 404 ? " (not found)" : r.code == 405 ? " (not a WebDAV URL?)" : "")); + } + Node ms = Xml.parse(r.body); + List out = new ArrayList(); + for (Node rn : ms.children(D, "response")) { + Response res = new Response(); + Node href = rn.child(D, "href"); + res.href = href == null ? "" : href.text(); + for (Node ps : rn.children(D, "propstat")) { + Node st = ps.child(D, "status"); + if (st != null && st.text().indexOf(" 200") < 0) continue; + Node prop = ps.child(D, "prop"); + if (prop == null) continue; + for (Node p : prop.kids) res.props.put(p.ns + p.name, p); + } + out.add(res); + } + return out; + } + + static String propfind(String props) { + return "" + + "" + + "" + props + ""; + } + + static String hrefIn(Response r, String ns, String name) { + Node n = r.prop(ns, name); + if (n == null) return null; + Node h = n.find(D, "href"); + return h == null ? null : h.text(); + } + + // ------------------------------------------------------------- discovery + + /** Finds the calendar-home-set URL for the logged-in user. */ + public String calendarHome() throws IOException { + List candidates = new ArrayList(); + String path = URI.create(url).getPath(); + String o = origin(url); + if (path == null || path.length() <= 1) { + candidates.add(o + "/remote.php/dav/"); + candidates.add(o + "/.well-known/caldav"); + candidates.add(o + "/"); + } else { + candidates.add(url.endsWith("/") ? url : url + "/"); + } + String principal = null; + IOException last = null; + for (String c : candidates) { + try { + List rs = multistatus("PROPFIND", c, "0", propfind("")); + for (Response r : rs) { + String h = hrefIn(r, D, "current-user-principal"); + if (h != null) principal = abs(c, h); + } + if (principal != null) break; + } catch (DavException e) { + if (e.code == 401 || e.code == DavException.REDIRECT) throw e; + last = e; + } + } + if (principal == null) { + throw last != null ? last : new DavException(0, "Server did not report a CalDAV principal"); + } + List rs = multistatus("PROPFIND", principal, "0", propfind("")); + for (Response r : rs) { + String h = hrefIn(r, C, "calendar-home-set"); + if (h != null) return abs(principal, h); + } + return principal; // some servers keep calendars directly under the principal + } + + public List calendars(String home) throws IOException { + List rs = multistatus("PROPFIND", home, "1", propfind( + "" + + "")); + List out = new ArrayList(); + for (Response r : rs) { + Node rt = r.prop(D, "resourcetype"); + if (rt == null || rt.child(C, "calendar") == null) continue; + Node comps = r.prop(C, "supported-calendar-component-set"); + if (comps != null && !comps.kids.isEmpty()) { + boolean ev = false; + for (Node k : comps.kids) ev |= k.name.equals("comp") && k.attr("name").equalsIgnoreCase("VEVENT"); + if (!ev) continue; + } + Cal c = new Cal(); + c.href = abs(home, r.href); + String dn = r.text(D, "displayname"); + c.name = dn == null || dn.length() == 0 ? lastSegment(c.href) : dn; + c.color = parseColor(r.text(A, "calendar-color"), c.color); + String ctag = r.text(CS, "getctag"); + c.ctag = ctag == null ? "" : ctag; + Node priv = r.prop(D, "current-user-privilege-set"); + if (priv != null) { + c.writable = priv.find(D, "write") != null || priv.find(D, "write-content") != null + || priv.find(D, "all") != null; + } + out.add(c); + } + return out; + } + + static String lastSegment(String href) { + String h = href.endsWith("/") ? href.substring(0, href.length() - 1) : href; + return Uri.decode(h.substring(h.lastIndexOf('/') + 1)); + } + + static int parseColor(String s, int def) { + if (s == null) return def; + s = s.trim(); + if (!s.startsWith("#")) return def; + try { + if (s.length() == 7) return 0xFF000000 | Integer.parseInt(s.substring(1), 16); + if (s.length() == 9) return 0xFF000000 | Integer.parseInt(s.substring(1, 7), 16); + } catch (NumberFormatException ignored) { + } + return def; + } + + // ------------------------------------------------------------------ events + + public List events(Cal cal, long from, long to) throws IOException { + String f = ICal.utc(from), t = ICal.utc(to); + String body = "" + + "" + + "" + + "" + + "" + + "" + + ""; + List rs = multistatus("REPORT", cal.href, "1", body); + List out = new ArrayList(); + for (Response r : rs) { + String data = r.text(C, "calendar-data"); + if (data == null || data.length() == 0) continue; + String etag = r.text(D, "getetag"); + String href = abs(cal.href, r.href); + List vs = ICal.parse(data); + boolean recurring = vs.size() > 1; + for (ICal.VEvent v : vs) if (v.rrule != null || v.recurrenceId != null || v.hasRdate) recurring = true; + // Overridden instances (when the server didn't expand) replace generated ones. + Map overrides = new HashMap(); + for (ICal.VEvent v : vs) { + if (v.recurrenceId != null && v.rrule == null && hasMasterWithRule(vs)) { + try { + overrides.put(ICal.parseTime(v.recurrenceId, null, null)[0], v); + } catch (RuntimeException ignored) { + } + } + } + for (ICal.VEvent v : vs) { + if (overrides.containsValue(v)) continue; + for (long[] occ : ICal.occurrences(v, from, to)) { + ICal.VEvent src = overrides.containsKey(occ[0]) ? overrides.get(occ[0]) : v; + Ev e = new Ev(); + e.cal = cal.href; + e.href = href; + e.etag = etag == null ? "" : etag; + e.uid = src.uid; + e.title = src.summary; + e.location = src.location; + e.descr = src.description; + e.start = src == v ? occ[0] : src.start; + e.end = src == v ? occ[1] : src.end; + e.allDay = src.allDay; + e.recurring = recurring; + out.add(e); + } + } + } + return out; + } + + static boolean hasMasterWithRule(List vs) { + for (ICal.VEvent v : vs) if (v.rrule != null) return true; + return false; + } + + /** GET a single object. Returns {ics, etag}. */ + public String[] get(String href) throws IOException { + Http.Resp r = send("GET", href, null, null, null); + if (r.code != 200) throw new DavException(r.code, "Could not load event: HTTP " + r.code); + return new String[] {r.body, r.etag}; + } + + public void create(String calHref, String uid, String ics) throws IOException { + String href = abs(calHref.endsWith("/") ? calHref : calHref + "/", uid + ".ics"); + Http.Resp r = send("PUT", href, null, ics, new String[] {"If-None-Match", "*"}); + if (r.code != 201 && r.code != 204 && r.code != 200) { + throw new DavException(r.code, "Saving failed: HTTP " + r.code); + } + } + + public void update(String href, String etag, String ics) throws IOException { + String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag}; + Http.Resp r = send("PUT", href, null, ics, h); + if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again."); + if (r.code != 201 && r.code != 204 && r.code != 200) { + throw new DavException(r.code, "Saving failed: HTTP " + r.code); + } + } + + public void delete(String href, String etag) throws IOException { + String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag}; + Http.Resp r = send("DELETE", href, null, null, h); + if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again."); + if (r.code != 204 && r.code != 200 && r.code != 404) { + throw new DavException(r.code, "Delete failed: HTTP " + r.code); + } + } + + public void close() { + http.close(); + } + + /** Minimal percent-decoder (java.net.URLDecoder would turn '+' into spaces). */ + static final class Uri { + static String decode(String s) { + try { + byte[] buf = new byte[s.length()]; + int n = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' && i + 2 < s.length()) { + buf[n++] = (byte) Integer.parseInt(s.substring(i + 1, i + 3), 16); + i += 2; + } else if (c < 128) { + buf[n++] = (byte) c; + } else { + return s; + } + } + return new String(buf, 0, n, "UTF-8"); + } catch (Exception e) { + return s; + } + } + } +} diff --git a/src/org/gingercal/DavException.java b/src/org/gingercal/DavException.java new file mode 100644 index 0000000..c73699c --- /dev/null +++ b/src/org/gingercal/DavException.java @@ -0,0 +1,32 @@ +package org.gingercal; + +import java.io.IOException; + +/** A server/protocol problem with a message that can be shown to the user as-is. */ +public class DavException extends IOException { + public final int code; + /** code for redirect problems (forced HTTPS, loops); retrying another URL won't help. */ + public static final int REDIRECT = -2; + + public DavException(int code, String msg) { + super(msg); + this.code = code; + } + + /** Thrown when the TLS certificate isn't trusted (or doesn't match the pin). */ + public static class UntrustedCert extends IOException { + public final String fingerprint, subject, issuer, expires; + public final boolean pinMismatch; + + public UntrustedCert(String fingerprint, String subject, String issuer, String expires, + boolean pinMismatch) { + super(pinMismatch ? "Server certificate changed (does not match the pinned fingerprint)" + : "Server certificate is not trusted by this phone"); + this.fingerprint = fingerprint; + this.subject = subject; + this.issuer = issuer; + this.expires = expires; + this.pinMismatch = pinMismatch; + } + } +} diff --git a/src/org/gingercal/Http.java b/src/org/gingercal/Http.java new file mode 100644 index 0000000..64e27d4 --- /dev/null +++ b/src/org/gingercal/Http.java @@ -0,0 +1,20 @@ +package org.gingercal; + +import java.io.IOException; + +/** Minimal HTTP transport so the CalDAV logic doesn't depend on Android classes. */ +public interface Http { + + /** headers: alternating name, value. body/contentType may be null. */ + Resp exec(String method, String url, String[] headers, String body, String contentType) + throws IOException; + + void close(); + + class Resp { + public int code; + public String body = ""; + public String etag; + public String location; + } +} diff --git a/src/org/gingercal/ICal.java b/src/org/gingercal/ICal.java new file mode 100644 index 0000000..3c5fcfd --- /dev/null +++ b/src/org/gingercal/ICal.java @@ -0,0 +1,552 @@ +package org.gingercal; + +import java.io.UnsupportedEncodingException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +/** Just enough iCalendar (RFC 5545) to read, create and edit simple VEVENTs. */ +public class ICal { + + public static final long MIN = 60000L, HOUR = 60 * MIN, DAY = 24 * HOUR; + static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + + public static class Prop { + public String name = ""; + public final Map params = new HashMap(); + public String value = ""; + + public String param(String k) { + return params.get(k); + } + } + + public static class VEvent { + public String uid = "", summary = "", location = "", description = ""; + public String rrule, recurrenceId; + public long start, end; + public boolean allDay; + public TimeZone tz = TimeZone.getDefault(); + public final List exdates = new ArrayList(); + public int sequence; + /** Minutes before start of the first display alarm, or -1 when there is none. */ + public int alarmMin = -1; + boolean hasEnd; + long duration = -1; + boolean hasRdate; + } + + // ---------------------------------------------------------------- parsing + + public static List unfold(String s) { + List out = new ArrayList(); + String[] raw = s.split("\r\n|\n|\r"); + for (int i = 0; i < raw.length; i++) { + String l = raw[i]; + if ((l.startsWith(" ") || l.startsWith("\t")) && !out.isEmpty()) { + int last = out.size() - 1; + out.set(last, out.get(last) + l.substring(1)); + } else if (l.length() > 0) { + out.add(l); + } + } + return out; + } + + public static Prop prop(String line) { + Prop p = new Prop(); + boolean q = false; + int colon = -1; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c == '"') q = !q; + else if (c == ':' && !q) { + colon = i; + break; + } + } + String head = colon < 0 ? line : line.substring(0, colon); + p.value = colon < 0 ? "" : line.substring(colon + 1); + List parts = new ArrayList(); + q = false; + int st = 0; + for (int i = 0; i < head.length(); i++) { + char c = head.charAt(i); + if (c == '"') q = !q; + else if (c == ';' && !q) { + parts.add(head.substring(st, i)); + st = i + 1; + } + } + parts.add(head.substring(st)); + p.name = parts.get(0).toUpperCase(Locale.US); + for (int i = 1; i < parts.size(); i++) { + String kv = parts.get(i); + int eq = kv.indexOf('='); + if (eq < 0) continue; + String v = kv.substring(eq + 1); + if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + p.params.put(kv.substring(0, eq).toUpperCase(Locale.US), v); + } + return p; + } + + public static String unescape(String s) { + if (s.indexOf('\\') < 0) return s; + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length()) { + char n = s.charAt(++i); + if (n == 'n' || n == 'N') b.append('\n'); + else b.append(n); + } else { + b.append(c); + } + } + return b.toString(); + } + + public static String escape(String s) { + StringBuilder b = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' || c == ';' || c == ',') b.append('\\').append(c); + else if (c == '\n') b.append("\\n"); + else if (c != '\r') b.append(c); + } + return b.toString(); + } + + public static TimeZone zone(String tzid) { + if (tzid == null || tzid.length() == 0) return TimeZone.getDefault(); + String id = tzid.startsWith("/") ? tzid.substring(1) : tzid; + TimeZone tz = TimeZone.getTimeZone(id); + // Unknown ids (e.g. Outlook's "Romance Standard Time") come back as GMT. + if ("GMT".equals(tz.getID()) && !id.toUpperCase(Locale.US).contains("GMT") + && !id.toUpperCase(Locale.US).contains("UTC")) { + return TimeZone.getDefault(); + } + return tz; + } + + /** Parses DATE / DATE-TIME values. Returns {millis, allDay(0/1)}. */ + public static long[] parseTime(String v, String tzid, String valueType) { + v = v.trim(); + int comma = v.indexOf(','); + if (comma > 0) v = v.substring(0, comma); + boolean date = "DATE".equalsIgnoreCase(valueType) || v.length() == 8; + int y = Integer.parseInt(v.substring(0, 4)); + int mo = Integer.parseInt(v.substring(4, 6)) - 1; + int d = Integer.parseInt(v.substring(6, 8)); + if (date) { + Calendar c = Calendar.getInstance(TimeZone.getDefault()); + c.clear(); + c.set(y, mo, d, 0, 0, 0); + return new long[] {c.getTimeInMillis(), 1}; + } + int h = Integer.parseInt(v.substring(9, 11)); + int mi = Integer.parseInt(v.substring(11, 13)); + int s = v.length() >= 15 ? Integer.parseInt(v.substring(13, 15)) : 0; + TimeZone tz = v.endsWith("Z") ? UTC : zone(tzid); + Calendar c = Calendar.getInstance(tz); + c.clear(); + c.set(y, mo, d, h, mi, s); + return new long[] {c.getTimeInMillis(), 0}; + } + + /** RFC 5545 DURATION, e.g. P1D, PT1H30M, -PT15M, P2W. */ + public static long parseDuration(String v) { + v = v.trim().toUpperCase(Locale.US); + int sign = 1; + int i = 0; + if (v.startsWith("-")) { + sign = -1; + i = 1; + } else if (v.startsWith("+")) { + i = 1; + } + long total = 0; + long num = 0; + boolean time = false; + for (; i < v.length(); i++) { + char c = v.charAt(i); + if (c >= '0' && c <= '9') num = num * 10 + (c - '0'); + else if (c == 'T') time = true; + else if (c == 'W') { total += num * 7 * DAY; num = 0; } + else if (c == 'D') { total += num * DAY; num = 0; } + else if (c == 'H') { total += num * HOUR; num = 0; } + else if (c == 'M' && time) { total += num * MIN; num = 0; } + else if (c == 'S') { total += num * 1000L; num = 0; } + } + return sign * total; + } + + /** Parses all top-level VEVENTs (alarms and other nested components are skipped). */ + public static List parse(String ics) { + List out = new ArrayList(); + List stack = new ArrayList(); + VEvent cur = null; + boolean inAlarm = false; + String alarmAction = null; + long alarmTrigger = 0; + boolean alarmRelEnd = false; + boolean alarmHasTrigger = false; + for (String line : unfold(ics)) { + Prop p = prop(line); + if (p.name.equals("BEGIN")) { + String comp = p.value.trim().toUpperCase(Locale.US); + stack.add(comp); + if (comp.equals("VEVENT") && stack.size() == 2) { + cur = new VEvent(); + } else if (comp.equals("VALARM") && cur != null) { + inAlarm = true; + alarmAction = null; + alarmHasTrigger = false; + alarmRelEnd = false; + } + continue; + } + if (p.name.equals("END")) { + String comp = p.value.trim().toUpperCase(Locale.US); + if (comp.equals("VALARM") && inAlarm) { + inAlarm = false; + if (alarmHasTrigger && !alarmRelEnd && alarmTrigger <= 0 && cur.alarmMin < 0 + && (alarmAction == null || alarmAction.equals("DISPLAY") || alarmAction.equals("AUDIO"))) { + cur.alarmMin = (int) (-alarmTrigger / MIN); + } + } + if (comp.equals("VEVENT") && cur != null && stack.size() == 2) { + finish(cur); + out.add(cur); + cur = null; + } + if (!stack.isEmpty()) stack.remove(stack.size() - 1); + continue; + } + if (cur == null) continue; + if (inAlarm) { + if (p.name.equals("ACTION")) alarmAction = p.value.trim().toUpperCase(Locale.US); + if (p.name.equals("TRIGGER") && !"DATE-TIME".equalsIgnoreCase(p.param("VALUE"))) { + try { + alarmTrigger = parseDuration(p.value); + alarmHasTrigger = true; + alarmRelEnd = "END".equalsIgnoreCase(p.param("RELATED")); + } catch (RuntimeException ignored) { + } + } + continue; + } + if (stack.size() != 2) continue; + try { + if (p.name.equals("UID")) cur.uid = p.value.trim(); + else if (p.name.equals("SUMMARY")) cur.summary = unescape(p.value); + else if (p.name.equals("LOCATION")) cur.location = unescape(p.value); + else if (p.name.equals("DESCRIPTION")) cur.description = unescape(p.value); + else if (p.name.equals("RRULE")) cur.rrule = p.value.trim(); + else if (p.name.equals("RDATE")) cur.hasRdate = true; + else if (p.name.equals("SEQUENCE")) cur.sequence = Integer.parseInt(p.value.trim()); + else if (p.name.equals("RECURRENCE-ID")) { + cur.recurrenceId = p.value.trim(); + } else if (p.name.equals("DTSTART")) { + long[] t = parseTime(p.value, p.param("TZID"), p.param("VALUE")); + cur.start = t[0]; + cur.allDay = t[1] == 1; + String v = p.value.trim(); + cur.tz = v.endsWith("Z") ? UTC : zone(p.param("TZID")); + } else if (p.name.equals("DTEND")) { + cur.end = parseTime(p.value, p.param("TZID"), p.param("VALUE"))[0]; + cur.hasEnd = true; + } else if (p.name.equals("DURATION")) { + cur.duration = parseDuration(p.value); + } else if (p.name.equals("EXDATE")) { + String[] vals = p.value.split(","); + for (int i = 0; i < vals.length; i++) { + if (vals[i].trim().length() >= 8) { + cur.exdates.add(parseTime(vals[i], p.param("TZID"), p.param("VALUE"))[0]); + } + } + } + } catch (RuntimeException badValue) { + // ignore a malformed property rather than dropping the whole event + } + } + return out; + } + + static void finish(VEvent e) { + if (!e.hasEnd) { + if (e.duration >= 0) e.end = e.start + e.duration; + else e.end = e.allDay ? addDays(e.start, 1) : e.start; + } + if (e.end < e.start) e.end = e.start; + } + + /** Adds calendar days in the default zone (DST-safe for all-day dates). */ + public static long addDays(long t, int days) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(t); + c.add(Calendar.DAY_OF_MONTH, days); + return c.getTimeInMillis(); + } + + public static long startOfDay(long t) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(t); + c.set(Calendar.HOUR_OF_DAY, 0); + c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + return c.getTimeInMillis(); + } + + // ------------------------------------------------- client-side recurrence + // Used only if the server ignores . Covers the common rules. + + public static List occurrences(VEvent e, long from, long to) { + List out = new ArrayList(); + long dur = e.end - e.start; + if (e.rrule == null) { + if (e.end > from && e.start < to || e.start >= from && e.start < to) out.add(new long[] {e.start, e.end}); + return out; + } + Map r = new HashMap(); + String[] parts = e.rrule.split(";"); + for (int i = 0; i < parts.length; i++) { + int eq = parts[i].indexOf('='); + if (eq > 0) r.put(parts[i].substring(0, eq).toUpperCase(Locale.US), parts[i].substring(eq + 1)); + } + String freq = r.containsKey("FREQ") ? r.get("FREQ").toUpperCase(Locale.US) : "DAILY"; + int interval = r.containsKey("INTERVAL") ? Math.max(1, Integer.parseInt(r.get("INTERVAL"))) : 1; + int count = r.containsKey("COUNT") ? Integer.parseInt(r.get("COUNT")) : -1; + long until = Long.MAX_VALUE; + if (r.containsKey("UNTIL")) { + long[] u = parseTime(r.get("UNTIL"), null, null); + until = u[1] == 1 ? addDays(u[0], 1) - 1 : u[0]; + } + int[] byDay = null; + if (freq.equals("WEEKLY") && r.containsKey("BYDAY")) { + String[] days = r.get("BYDAY").split(","); + byDay = new int[days.length]; + for (int i = 0; i < days.length; i++) byDay[i] = weekday(days[i].replaceAll("[^A-Za-z]", "")); + } + Calendar c = Calendar.getInstance(e.allDay ? TimeZone.getDefault() : e.tz); + c.setTimeInMillis(e.start); + int field = freq.equals("YEARLY") ? Calendar.YEAR + : freq.equals("MONTHLY") ? Calendar.MONTH + : freq.equals("WEEKLY") ? Calendar.WEEK_OF_YEAR : Calendar.DAY_OF_MONTH; + int produced = 0; + for (int step = 0; step < 3000; step++) { + Calendar base = (Calendar) c.clone(); + base.add(field, step * interval); + List starts = new ArrayList(); + if (byDay != null) { + Calendar wk = (Calendar) base.clone(); + // move to the first day of that week (week starts on the event's weekday reference) + int dow = wk.get(Calendar.DAY_OF_WEEK); + wk.add(Calendar.DAY_OF_MONTH, -((dow - Calendar.MONDAY + 7) % 7)); + for (int d = 0; d < 7; d++) { + Calendar x = (Calendar) wk.clone(); + x.add(Calendar.DAY_OF_MONTH, d); + for (int k = 0; k < byDay.length; k++) { + if (x.get(Calendar.DAY_OF_WEEK) == byDay[k] && x.getTimeInMillis() >= e.start) { + starts.add(x.getTimeInMillis()); + } + } + } + } else { + starts.add(base.getTimeInMillis()); + } + for (int i = 0; i < starts.size(); i++) { + long s = starts.get(i); + if (s > until || (count >= 0 && produced >= count)) return out; + produced++; + if (e.exdates.contains(s)) continue; + if (s < to && s + dur > from || (dur == 0 && s >= from && s < to)) out.add(new long[] {s, s + dur}); + } + if (base.getTimeInMillis() >= to) break; + } + return out; + } + + static int weekday(String s) { + s = s.toUpperCase(Locale.US); + if (s.equals("MO")) return Calendar.MONDAY; + if (s.equals("TU")) return Calendar.TUESDAY; + if (s.equals("WE")) return Calendar.WEDNESDAY; + if (s.equals("TH")) return Calendar.THURSDAY; + if (s.equals("FR")) return Calendar.FRIDAY; + if (s.equals("SA")) return Calendar.SATURDAY; + return Calendar.SUNDAY; + } + + // ---------------------------------------------------------------- writing + + public static String utc(long t) { + SimpleDateFormat f = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US); + f.setTimeZone(UTC); + return f.format(new Date(t)); + } + + public static String date(long t) { + SimpleDateFormat f = new SimpleDateFormat("yyyyMMdd", Locale.US); + f.setTimeZone(TimeZone.getDefault()); + return f.format(new Date(t)); + } + + static void timeProps(List out, long start, long end, boolean allDay) { + if (allDay) { + out.add("DTSTART;VALUE=DATE:" + date(start)); + out.add("DTEND;VALUE=DATE:" + date(end)); + } else { + out.add("DTSTART:" + utc(start)); + out.add("DTEND:" + utc(end)); + } + } + + static void alarm(List out, int minutes) { + if (minutes < 0) return; + out.add("BEGIN:VALARM"); + out.add("ACTION:DISPLAY"); + out.add("DESCRIPTION:Reminder"); + out.add("TRIGGER:-PT" + minutes + "M"); + out.add("END:VALARM"); + } + + public static String newEvent(String uid, String summary, String location, String description, + long start, long end, boolean allDay, int alarmMin) { + List l = new ArrayList(); + l.add("BEGIN:VCALENDAR"); + l.add("VERSION:2.0"); + l.add("PRODID:-//GingerCal//Android 2.3//EN"); + l.add("BEGIN:VEVENT"); + l.add("UID:" + uid); + long now = System.currentTimeMillis(); + l.add("DTSTAMP:" + utc(now)); + l.add("CREATED:" + utc(now)); + l.add("LAST-MODIFIED:" + utc(now)); + timeProps(l, start, end, allDay); + l.add("SUMMARY:" + escape(summary)); + if (location.length() > 0) l.add("LOCATION:" + escape(location)); + if (description.length() > 0) l.add("DESCRIPTION:" + escape(description)); + alarm(l, alarmMin); + l.add("END:VEVENT"); + l.add("END:VCALENDAR"); + return join(l); + } + + /** True when the object holds a recurring series (we only edit single events). */ + public static boolean isRecurring(String ics) { + List vs = parse(ics); + if (vs.size() > 1) return true; + for (VEvent v : vs) if (v.rrule != null || v.recurrenceId != null || v.hasRdate) return true; + return false; + } + + static final String[] REPLACED = {"DTSTART", "DTEND", "DURATION", "SUMMARY", "LOCATION", + "DESCRIPTION", "DTSTAMP", "LAST-MODIFIED", "SEQUENCE"}; + + /** + * Rewrites the editable fields of the (single) VEVENT, keeping every other property + * (attendees, categories, URL, ...) untouched. alarmMin == -2 keeps existing alarms. + */ + public static String modify(String ics, String summary, String location, String description, + long start, long end, boolean allDay, int alarmMin) { + List in = unfold(ics); + List out = new ArrayList(); + List stack = new ArrayList(); + boolean inTarget = false, done = false, skipAlarm = false; + int sequence = 0; + for (String line : in) { + Prop p = prop(line); + if (p.name.equals("BEGIN")) { + String comp = p.value.trim().toUpperCase(Locale.US); + stack.add(comp); + if (comp.equals("VEVENT") && stack.size() == 2 && !done) inTarget = true; + if (comp.equals("VALARM") && inTarget && alarmMin != -2) { + skipAlarm = true; + continue; + } + } else if (p.name.equals("END")) { + String comp = p.value.trim().toUpperCase(Locale.US); + if (comp.equals("VALARM") && skipAlarm) { + skipAlarm = false; + stack.remove(stack.size() - 1); + continue; + } + if (comp.equals("VEVENT") && inTarget && stack.size() == 2) { + long now = System.currentTimeMillis(); + out.add("DTSTAMP:" + utc(now)); + out.add("LAST-MODIFIED:" + utc(now)); + out.add("SEQUENCE:" + (sequence + 1)); + timeProps(out, start, end, allDay); + out.add("SUMMARY:" + escape(summary)); + if (location.length() > 0) out.add("LOCATION:" + escape(location)); + if (description.length() > 0) out.add("DESCRIPTION:" + escape(description)); + if (alarmMin != -2) alarm(out, alarmMin); + inTarget = false; + done = true; + } + if (!stack.isEmpty()) stack.remove(stack.size() - 1); + } else if (skipAlarm) { + continue; + } else if (inTarget && stack.size() == 2) { + if (p.name.equals("SEQUENCE")) { + try { + sequence = Integer.parseInt(p.value.trim()); + } catch (RuntimeException ignored) { + } + } + boolean drop = false; + for (int i = 0; i < REPLACED.length; i++) if (REPLACED[i].equals(p.name)) drop = true; + if (drop) continue; + } + out.add(line); + } + return join(out); + } + + static String join(List lines) { + StringBuilder b = new StringBuilder(); + for (String l : lines) b.append(fold(l)).append("\r\n"); + return b.toString(); + } + + /** Folds to 75 octets without splitting UTF-8 sequences. */ + static String fold(String line) { + try { + if (line.getBytes("UTF-8").length <= 75) return line; + StringBuilder b = new StringBuilder(); + int bytes = 0; + int limit = 75; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + int n; + String unit; + if (Character.isHighSurrogate(c) && i + 1 < line.length()) { + unit = line.substring(i, i + 2); + i++; + } else { + unit = String.valueOf(c); + } + n = unit.getBytes("UTF-8").length; + if (bytes + n > limit) { + b.append("\r\n "); + bytes = 1; + limit = 75; + } + b.append(unit); + bytes += n; + } + return b.toString(); + } catch (UnsupportedEncodingException e) { + return line; + } + } +} diff --git a/src/org/gingercal/Model.java b/src/org/gingercal/Model.java new file mode 100644 index 0000000..a4a40e8 --- /dev/null +++ b/src/org/gingercal/Model.java @@ -0,0 +1,36 @@ +package org.gingercal; + +import java.util.List; +import java.util.Map; + +/** Plain data holders shared by the sync engine, the database and the UI. */ +public final class Model { + private Model() {} + + public static class Cal { + public String href = "", name = ""; + public int color = 0xFF0082C9; // Nextcloud blue + public boolean enabled = true, writable = true; + public String ctag = ""; + /** Start-of-window (ms) the cached events were fetched for; 0 = never. */ + public long synced; + } + + public static class Ev { + public long id; + public String cal = "", href = "", etag = "", uid = ""; + public String title = "", location = "", descr = ""; + public long start, end; + public boolean allDay, recurring; + public int color; + public boolean writable = true; + } + + /** Persistence used by the sync engine (SQLite on the phone, in-memory in tests). */ + public interface Store { + Map calendars(); + void saveCalendar(Cal c); + void deleteCalendar(String href); + void replaceEvents(String calHref, List events); + } +} diff --git a/src/org/gingercal/Syncer.java b/src/org/gingercal/Syncer.java new file mode 100644 index 0000000..c938726 --- /dev/null +++ b/src/org/gingercal/Syncer.java @@ -0,0 +1,70 @@ +package org.gingercal; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; +import org.gingercal.Model.Store; + +/** One-way server-to-phone cache refresh. Writes go straight to the server. */ +public class Syncer { + + public static class Result { + public int calendars, fetched, skipped, events; + + public String summary() { + return calendars + " calendars: " + fetched + " refreshed (" + events + " events)" + + (skipped > 0 ? ", " + skipped + " unchanged" : ""); + } + } + + public static Result sync(Dav dav, Store store, long now, int pastDays, int futureDays, boolean force) + throws IOException { + Result res = new Result(); + String home = dav.calendarHome(); + List remote = dav.calendars(home); + Map local = store.calendars(); + Set seen = new HashSet(); + long from = ICal.addDays(ICal.startOfDay(now), -pastDays); + long to = ICal.addDays(ICal.startOfDay(now), futureDays + 1); + + for (Cal r : remote) { + seen.add(r.href); + Cal old = local.get(r.href); + if (old != null) { + r.enabled = old.enabled; + r.synced = old.synced; + } + boolean unchanged = !force && old != null && r.ctag.length() > 0 && r.ctag.equals(old.ctag) + && old.synced == from; + if (!r.enabled) { + if (old == null || old.synced != 0) store.replaceEvents(r.href, new ArrayList()); + r.synced = 0; + store.saveCalendar(r); + continue; + } + res.calendars++; + if (unchanged) { + res.skipped++; + r.ctag = old.ctag; + store.saveCalendar(r); + continue; + } + List evs = dav.events(r, from, to); + store.replaceEvents(r.href, evs); + res.events += evs.size(); + r.synced = from; + store.saveCalendar(r); + res.fetched++; + } + for (String href : local.keySet()) { + if (!seen.contains(href)) store.deleteCalendar(href); + } + return res; + } +} diff --git a/src/org/gingercal/Tls.java b/src/org/gingercal/Tls.java new file mode 100644 index 0000000..cdac908 --- /dev/null +++ b/src/org/gingercal/Tls.java @@ -0,0 +1,112 @@ +package org.gingercal; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.text.SimpleDateFormat; +import java.util.Locale; + +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +/** Certificate pinning for servers the phone can't validate itself (self-signed bridge). */ +public final class Tls { + private Tls() {} + + public static String sha256(byte[] der) { + try { + byte[] d = MessageDigest.getInstance("SHA-256").digest(der); + StringBuilder b = new StringBuilder(); + for (int i = 0; i < d.length; i++) { + if (i > 0) b.append(':'); + String x = Integer.toHexString(d[i] & 0xff).toUpperCase(Locale.US); + if (x.length() == 1) b.append('0'); + b.append(x); + } + return b.toString(); + } catch (GeneralSecurityException e) { + return ""; + } + } + + public static String normPin(String s) { + if (s == null) return ""; + return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US); + } + + /** Accepts the system CAs, or exactly one pinned leaf certificate (SHA-256). */ + public static class PinTrust implements X509TrustManager { + final String pin; + final X509TrustManager system; + String seenFp, seenSubject, seenIssuer, seenExpiry; + boolean rejected, mismatch; + + public PinTrust(String pin) throws GeneralSecurityException { + this.pin = normPin(pin); + TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + f.init((KeyStore) null); + X509TrustManager s = null; + TrustManager[] tms = f.getTrustManagers(); + for (int i = 0; i < tms.length; i++) if (tms[i] instanceof X509TrustManager) s = (X509TrustManager) tms[i]; + system = s; + } + + public boolean pinned() { + return pin.length() > 0; + } + + public void reset() { + seenFp = null; + rejected = false; + mismatch = false; + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + throw new CertificateException("client certs not supported"); + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (chain == null || chain.length == 0) throw new CertificateException("empty chain"); + X509Certificate leaf = chain[0]; + seenFp = sha256(leaf.getEncoded()); + seenSubject = leaf.getSubjectDN().getName(); + seenIssuer = leaf.getIssuerDN().getName(); + seenExpiry = new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(leaf.getNotAfter()); + if (pinned()) { + if (normPin(seenFp).equals(pin)) return; + rejected = true; + mismatch = true; + throw new CertificateException("certificate does not match pin"); + } + try { + if (system == null) throw new CertificateException("no system trust store"); + system.checkServerTrusted(chain, authType); + } catch (CertificateException e) { + rejected = true; + throw e; + } + } + + public X509Certificate[] getAcceptedIssuers() { + return system == null ? new X509Certificate[0] : system.getAcceptedIssuers(); + } + + public IOException translate(IOException e) { + if (rejected && seenFp != null) { + return new DavException.UntrustedCert(seenFp, seenSubject, seenIssuer, seenExpiry, mismatch); + } + if (e instanceof SSLException && seenFp == null) { + return new DavException(0, "TLS handshake failed before any certificate was sent. The server " + + "most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the " + + "legacy-TLS bridge (see README) or use http:// on your LAN. (" + e.getMessage() + ")"); + } + return e; + } + } + +} diff --git a/src/org/gingercal/Xml.java b/src/org/gingercal/Xml.java new file mode 100644 index 0000000..4abf34f --- /dev/null +++ b/src/org/gingercal/Xml.java @@ -0,0 +1,127 @@ +package org.gingercal; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.helpers.DefaultHandler; + +/** Tiny namespace-aware XML tree built with SAX (works on Android 2.3 and desktop Java). */ +public class Xml { + + public static class Node { + public String ns = ""; + public String name = ""; + public final StringBuilder sb = new StringBuilder(); + public final List kids = new ArrayList(); + public final java.util.Map attrs = new java.util.HashMap(); + + public String attr(String name) { + String v = attrs.get(name); + return v == null ? "" : v; + } + + public boolean is(String ns, String name) { + return this.name.equals(name) && this.ns.equals(ns); + } + + public Node child(String ns, String name) { + for (int i = 0; i < kids.size(); i++) { + Node k = kids.get(i); + if (k.is(ns, name)) return k; + } + return null; + } + + public List children(String ns, String name) { + List out = new ArrayList(); + for (int i = 0; i < kids.size(); i++) { + Node k = kids.get(i); + if (k.is(ns, name)) out.add(k); + } + return out; + } + + /** Depth-first search for the first descendant with this name. */ + public Node find(String ns, String name) { + for (int i = 0; i < kids.size(); i++) { + Node k = kids.get(i); + if (k.is(ns, name)) return k; + Node f = k.find(ns, name); + if (f != null) return f; + } + return null; + } + + public String text() { + return sb.toString().trim(); + } + } + + public static Node parse(String xml) throws IOException { + final Node root = new Node(); + final ArrayList stack = new ArrayList(); + stack.add(root); + DefaultHandler h = new DefaultHandler() { + @Override + public void startElement(String uri, String local, String qName, Attributes atts) { + Node n = new Node(); + n.ns = uri == null ? "" : uri; + if (local != null && local.length() > 0) { + n.name = local; + } else { + int c = qName.indexOf(':'); + n.name = c >= 0 ? qName.substring(c + 1) : qName; + } + for (int i = 0; i < atts.getLength(); i++) { + String an = atts.getLocalName(i); + if (an == null || an.length() == 0) an = atts.getQName(i); + n.attrs.put(an, atts.getValue(i)); + } + stack.get(stack.size() - 1).kids.add(n); + stack.add(n); + } + + @Override + public void endElement(String uri, String local, String qName) { + if (stack.size() > 1) stack.remove(stack.size() - 1); + } + + @Override + public void characters(char[] ch, int start, int length) { + stack.get(stack.size() - 1).sb.append(ch, start, length); + } + }; + try { + SAXParserFactory f = SAXParserFactory.newInstance(); + f.setNamespaceAware(true); + SAXParser p = f.newSAXParser(); + p.parse(new InputSource(new StringReader(xml)), h); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Bad XML from server: " + e.getMessage()); + } + if (root.kids.isEmpty()) throw new IOException("Empty XML from server"); + return root.kids.get(0); + } + + public static String esc(String s) { + StringBuilder b = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '<') b.append("<"); + else if (c == '>') b.append(">"); + else if (c == '&') b.append("&"); + else if (c == '"') b.append("""); + else b.append(c); + } + return b.toString(); + } +} diff --git a/stubs/android/app/Activity.java b/stubs/android/app/Activity.java new file mode 100644 index 0000000..084da57 --- /dev/null +++ b/stubs/android/app/Activity.java @@ -0,0 +1,15 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class Activity extends android.view.ContextThemeWrapper { + public Activity() { throw new RuntimeException("Stub!"); } + protected void onCreate(android.os.Bundle savedInstanceState) { throw new RuntimeException("Stub!"); } + protected void onResume() { throw new RuntimeException("Stub!"); } + protected void onPause() { throw new RuntimeException("Stub!"); } + public void setTitle(CharSequence title) { throw new RuntimeException("Stub!"); } + public void setContentView(android.view.View view) { throw new RuntimeException("Stub!"); } + public boolean onCreateOptionsMenu(android.view.Menu menu) { throw new RuntimeException("Stub!"); } + public boolean onOptionsItemSelected(android.view.MenuItem item) { throw new RuntimeException("Stub!"); } + public android.content.Intent getIntent() { throw new RuntimeException("Stub!"); } + public void finish() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/AlarmManager.java b/stubs/android/app/AlarmManager.java new file mode 100644 index 0000000..19be27d --- /dev/null +++ b/stubs/android/app/AlarmManager.java @@ -0,0 +1,9 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class AlarmManager { + AlarmManager() { throw new RuntimeException("Stub!"); } + public void set(int type, long triggerAtTime, PendingIntent operation) { throw new RuntimeException("Stub!"); } + public void setInexactRepeating(int type, long triggerAtTime, long interval, PendingIntent operation) { throw new RuntimeException("Stub!"); } + public void cancel(PendingIntent operation) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/AlertDialog.java b/stubs/android/app/AlertDialog.java new file mode 100644 index 0000000..80f6edd --- /dev/null +++ b/stubs/android/app/AlertDialog.java @@ -0,0 +1,16 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class AlertDialog extends Dialog { + protected AlertDialog() { throw new RuntimeException("Stub!"); } + public static class Builder { + public Builder(android.content.Context context) { throw new RuntimeException("Stub!"); } + public Builder setTitle(CharSequence title) { throw new RuntimeException("Stub!"); } + public Builder setMessage(CharSequence message) { throw new RuntimeException("Stub!"); } + public Builder setPositiveButton(CharSequence text, android.content.DialogInterface.OnClickListener listener) { throw new RuntimeException("Stub!"); } + public Builder setNegativeButton(CharSequence text, android.content.DialogInterface.OnClickListener listener) { throw new RuntimeException("Stub!"); } + public Builder setNeutralButton(CharSequence text, android.content.DialogInterface.OnClickListener listener) { throw new RuntimeException("Stub!"); } + public Builder setItems(CharSequence[] items, android.content.DialogInterface.OnClickListener listener) { throw new RuntimeException("Stub!"); } + public AlertDialog show() { throw new RuntimeException("Stub!"); } + } +} diff --git a/stubs/android/app/DatePickerDialog.java b/stubs/android/app/DatePickerDialog.java new file mode 100644 index 0000000..b137685 --- /dev/null +++ b/stubs/android/app/DatePickerDialog.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class DatePickerDialog extends AlertDialog { + public DatePickerDialog(android.content.Context context, OnDateSetListener callBack, int year, int monthOfYear, int dayOfMonth) { throw new RuntimeException("Stub!"); } + public interface OnDateSetListener { void onDateSet(android.widget.DatePicker view, int year, int monthOfYear, int dayOfMonth); } +} diff --git a/stubs/android/app/Dialog.java b/stubs/android/app/Dialog.java new file mode 100644 index 0000000..3904aff --- /dev/null +++ b/stubs/android/app/Dialog.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class Dialog implements android.content.DialogInterface { + public void show() { throw new RuntimeException("Stub!"); } + public void dismiss() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/IntentService.java b/stubs/android/app/IntentService.java new file mode 100644 index 0000000..8f10a6a --- /dev/null +++ b/stubs/android/app/IntentService.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public abstract class IntentService extends Service { + public IntentService(String name) { throw new RuntimeException("Stub!"); } + protected abstract void onHandleIntent(android.content.Intent intent); +} diff --git a/stubs/android/app/Notification.java b/stubs/android/app/Notification.java new file mode 100644 index 0000000..5f7c780 --- /dev/null +++ b/stubs/android/app/Notification.java @@ -0,0 +1,9 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class Notification { + public int flags; + public int defaults; + public Notification(int icon, CharSequence tickerText, long when) { throw new RuntimeException("Stub!"); } + public void setLatestEventInfo(android.content.Context context, CharSequence contentTitle, CharSequence contentText, PendingIntent contentIntent) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/NotificationManager.java b/stubs/android/app/NotificationManager.java new file mode 100644 index 0000000..ead918c --- /dev/null +++ b/stubs/android/app/NotificationManager.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class NotificationManager { + NotificationManager() { throw new RuntimeException("Stub!"); } + public void notify(int id, Notification notification) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/PendingIntent.java b/stubs/android/app/PendingIntent.java new file mode 100644 index 0000000..9da2bbf --- /dev/null +++ b/stubs/android/app/PendingIntent.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public final class PendingIntent { + PendingIntent() { throw new RuntimeException("Stub!"); } + public static PendingIntent getBroadcast(android.content.Context context, int requestCode, android.content.Intent intent, int flags) { throw new RuntimeException("Stub!"); } + public static PendingIntent getActivity(android.content.Context context, int requestCode, android.content.Intent intent, int flags) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/ProgressDialog.java b/stubs/android/app/ProgressDialog.java new file mode 100644 index 0000000..61fb2e4 --- /dev/null +++ b/stubs/android/app/ProgressDialog.java @@ -0,0 +1,6 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class ProgressDialog extends AlertDialog { + public static ProgressDialog show(android.content.Context context, CharSequence title, CharSequence message) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/app/Service.java b/stubs/android/app/Service.java new file mode 100644 index 0000000..5d43777 --- /dev/null +++ b/stubs/android/app/Service.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public abstract class Service extends android.content.ContextWrapper { public Service() { super(null); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/app/TimePickerDialog.java b/stubs/android/app/TimePickerDialog.java new file mode 100644 index 0000000..b1faa9e --- /dev/null +++ b/stubs/android/app/TimePickerDialog.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class TimePickerDialog extends AlertDialog { + public TimePickerDialog(android.content.Context context, OnTimeSetListener callBack, int hourOfDay, int minute, boolean is24HourView) { throw new RuntimeException("Stub!"); } + public interface OnTimeSetListener { void onTimeSet(android.widget.TimePicker view, int hourOfDay, int minute); } +} diff --git a/stubs/android/content/BroadcastReceiver.java b/stubs/android/content/BroadcastReceiver.java new file mode 100644 index 0000000..0adb5fd --- /dev/null +++ b/stubs/android/content/BroadcastReceiver.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public abstract class BroadcastReceiver { + public BroadcastReceiver() { throw new RuntimeException("Stub!"); } + public abstract void onReceive(Context context, Intent intent); +} diff --git a/stubs/android/content/ComponentName.java b/stubs/android/content/ComponentName.java new file mode 100644 index 0000000..584dcf7 --- /dev/null +++ b/stubs/android/content/ComponentName.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public final class ComponentName { private ComponentName() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/content/Context.java b/stubs/android/content/Context.java new file mode 100644 index 0000000..2bae2a6 --- /dev/null +++ b/stubs/android/content/Context.java @@ -0,0 +1,15 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public abstract class Context { + public abstract Object getSystemService(String name); + public abstract android.content.SharedPreferences getSharedPreferences(String name, int mode); + public abstract android.content.res.Resources getResources(); + public abstract Context getApplicationContext(); + public abstract void startActivity(Intent intent); + public abstract ComponentName startService(Intent service); + public abstract void sendBroadcast(Intent intent); + public abstract Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter); + public abstract void unregisterReceiver(BroadcastReceiver receiver); + public abstract String getPackageName(); +} diff --git a/stubs/android/content/ContextWrapper.java b/stubs/android/content/ContextWrapper.java new file mode 100644 index 0000000..8d88556 --- /dev/null +++ b/stubs/android/content/ContextWrapper.java @@ -0,0 +1,16 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public class ContextWrapper extends Context { + public ContextWrapper(Context base) { throw new RuntimeException("Stub!"); } + public Object getSystemService(String name) { throw new RuntimeException("Stub!"); } + public SharedPreferences getSharedPreferences(String name, int mode) { throw new RuntimeException("Stub!"); } + public android.content.res.Resources getResources() { throw new RuntimeException("Stub!"); } + public Context getApplicationContext() { throw new RuntimeException("Stub!"); } + public void startActivity(Intent intent) { throw new RuntimeException("Stub!"); } + public ComponentName startService(Intent service) { throw new RuntimeException("Stub!"); } + public void sendBroadcast(Intent intent) { throw new RuntimeException("Stub!"); } + public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { throw new RuntimeException("Stub!"); } + public void unregisterReceiver(BroadcastReceiver receiver) { throw new RuntimeException("Stub!"); } + public String getPackageName() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/content/DialogInterface.java b/stubs/android/content/DialogInterface.java new file mode 100644 index 0000000..23b6f4f --- /dev/null +++ b/stubs/android/content/DialogInterface.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public interface DialogInterface { + void dismiss(); + interface OnClickListener { void onClick(DialogInterface dialog, int which); } +} diff --git a/stubs/android/content/Intent.java b/stubs/android/content/Intent.java new file mode 100644 index 0000000..4d38822 --- /dev/null +++ b/stubs/android/content/Intent.java @@ -0,0 +1,17 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public class Intent { + public Intent(Context packageContext, Class cls) { throw new RuntimeException("Stub!"); } + public Intent(String action) { throw new RuntimeException("Stub!"); } + public Intent putExtra(String name, boolean value) { throw new RuntimeException("Stub!"); } + public Intent putExtra(String name, long value) { throw new RuntimeException("Stub!"); } + public Intent putExtra(String name, String value) { throw new RuntimeException("Stub!"); } + public String getStringExtra(String name) { throw new RuntimeException("Stub!"); } + public boolean getBooleanExtra(String name, boolean defaultValue) { throw new RuntimeException("Stub!"); } + public long getLongExtra(String name, long defaultValue) { throw new RuntimeException("Stub!"); } + public String getAction() { throw new RuntimeException("Stub!"); } + public Intent setAction(String action) { throw new RuntimeException("Stub!"); } + public Intent setFlags(int flags) { throw new RuntimeException("Stub!"); } + public Intent setPackage(String packageName) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/content/IntentFilter.java b/stubs/android/content/IntentFilter.java new file mode 100644 index 0000000..83eb9f3 --- /dev/null +++ b/stubs/android/content/IntentFilter.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public class IntentFilter { public IntentFilter(String action) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/content/SharedPreferences.java b/stubs/android/content/SharedPreferences.java new file mode 100644 index 0000000..93f5e49 --- /dev/null +++ b/stubs/android/content/SharedPreferences.java @@ -0,0 +1,17 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content; + +public interface SharedPreferences { + String getString(String key, String defValue); + int getInt(String key, int defValue); + long getLong(String key, long defValue); + boolean getBoolean(String key, boolean defValue); + Editor edit(); + interface Editor { + Editor putString(String key, String value); + Editor putInt(String key, int value); + Editor putLong(String key, long value); + Editor putBoolean(String key, boolean value); + boolean commit(); + } +} diff --git a/stubs/android/content/res/Resources.java b/stubs/android/content/res/Resources.java new file mode 100644 index 0000000..95862a2 --- /dev/null +++ b/stubs/android/content/res/Resources.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content.res; + +public class Resources { + public int getIdentifier(String name, String defType, String defPackage) { throw new RuntimeException("Stub!"); } + public android.util.DisplayMetrics getDisplayMetrics() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/database/Cursor.java b/stubs/android/database/Cursor.java new file mode 100644 index 0000000..b37e1b9 --- /dev/null +++ b/stubs/android/database/Cursor.java @@ -0,0 +1,11 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.database; + +public interface Cursor { + boolean moveToNext(); + String getString(int columnIndex); + int getInt(int columnIndex); + long getLong(int columnIndex); + boolean isNull(int columnIndex); + void close(); +} diff --git a/stubs/android/database/sqlite/SQLiteDatabase.java b/stubs/android/database/sqlite/SQLiteDatabase.java new file mode 100644 index 0000000..9a1e426 --- /dev/null +++ b/stubs/android/database/sqlite/SQLiteDatabase.java @@ -0,0 +1,13 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.database.sqlite; + +public class SQLiteDatabase { + SQLiteDatabase() { throw new RuntimeException("Stub!"); } + public interface CursorFactory {} + public void execSQL(String sql) { throw new RuntimeException("Stub!"); } + public void execSQL(String sql, Object[] bindArgs) { throw new RuntimeException("Stub!"); } + public android.database.Cursor rawQuery(String sql, String[] selectionArgs) { throw new RuntimeException("Stub!"); } + public void beginTransaction() { throw new RuntimeException("Stub!"); } + public void setTransactionSuccessful() { throw new RuntimeException("Stub!"); } + public void endTransaction() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/database/sqlite/SQLiteOpenHelper.java b/stubs/android/database/sqlite/SQLiteOpenHelper.java new file mode 100644 index 0000000..440fa47 --- /dev/null +++ b/stubs/android/database/sqlite/SQLiteOpenHelper.java @@ -0,0 +1,10 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.database.sqlite; + +public abstract class SQLiteOpenHelper { + public SQLiteOpenHelper(android.content.Context context, String name, SQLiteDatabase.CursorFactory factory, int version) { throw new RuntimeException("Stub!"); } + public abstract void onCreate(SQLiteDatabase db); + public abstract void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion); + public SQLiteDatabase getWritableDatabase() { throw new RuntimeException("Stub!"); } + public SQLiteDatabase getReadableDatabase() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/net/ConnectivityManager.java b/stubs/android/net/ConnectivityManager.java new file mode 100644 index 0000000..fd96d42 --- /dev/null +++ b/stubs/android/net/ConnectivityManager.java @@ -0,0 +1,5 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.net; + +public class ConnectivityManager { ConnectivityManager() { throw new RuntimeException("Stub!"); } + public NetworkInfo getActiveNetworkInfo() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/net/NetworkInfo.java b/stubs/android/net/NetworkInfo.java new file mode 100644 index 0000000..d27f88a --- /dev/null +++ b/stubs/android/net/NetworkInfo.java @@ -0,0 +1,5 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.net; + +public class NetworkInfo { NetworkInfo() { throw new RuntimeException("Stub!"); } + public boolean isConnected() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/os/AsyncTask.java b/stubs/android/os/AsyncTask.java new file mode 100644 index 0000000..bd12039 --- /dev/null +++ b/stubs/android/os/AsyncTask.java @@ -0,0 +1,10 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.os; + +public abstract class AsyncTask { + public AsyncTask() { throw new RuntimeException("Stub!"); } + protected abstract Result doInBackground(Params... params); + protected void onPreExecute() { throw new RuntimeException("Stub!"); } + protected void onPostExecute(Result result) { throw new RuntimeException("Stub!"); } + public final AsyncTask execute(Params... params) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/os/Bundle.java b/stubs/android/os/Bundle.java new file mode 100644 index 0000000..7152408 --- /dev/null +++ b/stubs/android/os/Bundle.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.os; + +public final class Bundle { public Bundle() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/os/PowerManager.java b/stubs/android/os/PowerManager.java new file mode 100644 index 0000000..0c14c7c --- /dev/null +++ b/stubs/android/os/PowerManager.java @@ -0,0 +1,14 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.os; + +public class PowerManager { + PowerManager() { throw new RuntimeException("Stub!"); } + public WakeLock newWakeLock(int flags, String tag) { throw new RuntimeException("Stub!"); } + public class WakeLock { + WakeLock() { throw new RuntimeException("Stub!"); } + public void setReferenceCounted(boolean value) { throw new RuntimeException("Stub!"); } + public void acquire(long timeout) { throw new RuntimeException("Stub!"); } + public void release() { throw new RuntimeException("Stub!"); } + public boolean isHeld() { throw new RuntimeException("Stub!"); } + } +} diff --git a/stubs/android/text/format/DateFormat.java b/stubs/android/text/format/DateFormat.java new file mode 100644 index 0000000..c26e853 --- /dev/null +++ b/stubs/android/text/format/DateFormat.java @@ -0,0 +1,5 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.text.format; + +public class DateFormat { public DateFormat() { throw new RuntimeException("Stub!"); } + public static boolean is24HourFormat(android.content.Context context) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/util/DisplayMetrics.java b/stubs/android/util/DisplayMetrics.java new file mode 100644 index 0000000..ab3a675 --- /dev/null +++ b/stubs/android/util/DisplayMetrics.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.util; + +public class DisplayMetrics { public float density; public DisplayMetrics() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/view/ContextThemeWrapper.java b/stubs/android/view/ContextThemeWrapper.java new file mode 100644 index 0000000..6c3f0bb --- /dev/null +++ b/stubs/android/view/ContextThemeWrapper.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public class ContextThemeWrapper extends android.content.ContextWrapper { public ContextThemeWrapper() { super(null); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/view/Menu.java b/stubs/android/view/Menu.java new file mode 100644 index 0000000..1f20bcc --- /dev/null +++ b/stubs/android/view/Menu.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public interface Menu { MenuItem add(int groupId, int itemId, int order, CharSequence title); } diff --git a/stubs/android/view/MenuItem.java b/stubs/android/view/MenuItem.java new file mode 100644 index 0000000..f50359f --- /dev/null +++ b/stubs/android/view/MenuItem.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public interface MenuItem { int getItemId(); MenuItem setIcon(int iconRes); } diff --git a/stubs/android/view/View.java b/stubs/android/view/View.java new file mode 100644 index 0000000..86d5c13 --- /dev/null +++ b/stubs/android/view/View.java @@ -0,0 +1,16 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public class View { + public static final int VISIBLE = 0; + public static final int GONE = 8; + public View(android.content.Context context) { throw new RuntimeException("Stub!"); } + public interface OnClickListener { void onClick(View v); } + public void setOnClickListener(OnClickListener l) { throw new RuntimeException("Stub!"); } + public void setPadding(int left, int top, int right, int bottom) { throw new RuntimeException("Stub!"); } + public void setBackgroundColor(int color) { throw new RuntimeException("Stub!"); } + public void setVisibility(int visibility) { throw new RuntimeException("Stub!"); } + public Object getTag() { throw new RuntimeException("Stub!"); } + public void setTag(Object tag) { throw new RuntimeException("Stub!"); } + public void setEnabled(boolean enabled) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/view/ViewGroup.java b/stubs/android/view/ViewGroup.java new file mode 100644 index 0000000..51ed939 --- /dev/null +++ b/stubs/android/view/ViewGroup.java @@ -0,0 +1,13 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public abstract class ViewGroup extends View { + public ViewGroup(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public void addView(View child) { throw new RuntimeException("Stub!"); } + public void addView(View child, LayoutParams params) { throw new RuntimeException("Stub!"); } + public static class LayoutParams { public LayoutParams(int width, int height) { throw new RuntimeException("Stub!"); } } + public static class MarginLayoutParams extends LayoutParams { + public MarginLayoutParams(int width, int height) { super(width, height); throw new RuntimeException("Stub!"); } + public void setMargins(int left, int top, int right, int bottom) { throw new RuntimeException("Stub!"); } + } +} diff --git a/stubs/android/widget/AbsListView.java b/stubs/android/widget/AbsListView.java new file mode 100644 index 0000000..1aab271 --- /dev/null +++ b/stubs/android/widget/AbsListView.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public abstract class AbsListView extends AdapterView { public AbsListView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/Adapter.java b/stubs/android/widget/Adapter.java new file mode 100644 index 0000000..cd7d43a --- /dev/null +++ b/stubs/android/widget/Adapter.java @@ -0,0 +1,11 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public interface Adapter { + int getCount(); + Object getItem(int position); + long getItemId(int position); + android.view.View getView(int position, android.view.View convertView, android.view.ViewGroup parent); + int getItemViewType(int position); + int getViewTypeCount(); +} diff --git a/stubs/android/widget/AdapterView.java b/stubs/android/widget/AdapterView.java new file mode 100644 index 0000000..e437473 --- /dev/null +++ b/stubs/android/widget/AdapterView.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public abstract class AdapterView extends android.view.ViewGroup { + public AdapterView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public interface OnItemClickListener { void onItemClick(AdapterView parent, android.view.View view, int position, long id); } + public void setOnItemClickListener(OnItemClickListener listener) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/widget/BaseAdapter.java b/stubs/android/widget/BaseAdapter.java new file mode 100644 index 0000000..6a91edd --- /dev/null +++ b/stubs/android/widget/BaseAdapter.java @@ -0,0 +1,11 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public abstract class BaseAdapter implements ListAdapter, SpinnerAdapter { + public BaseAdapter() { throw new RuntimeException("Stub!"); } + public void notifyDataSetChanged() { throw new RuntimeException("Stub!"); } + public boolean areAllItemsEnabled() { throw new RuntimeException("Stub!"); } + public boolean isEnabled(int position) { throw new RuntimeException("Stub!"); } + public int getItemViewType(int position) { throw new RuntimeException("Stub!"); } + public int getViewTypeCount() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/widget/Button.java b/stubs/android/widget/Button.java new file mode 100644 index 0000000..f379130 --- /dev/null +++ b/stubs/android/widget/Button.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class Button extends TextView { public Button(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/CheckBox.java b/stubs/android/widget/CheckBox.java new file mode 100644 index 0000000..7914111 --- /dev/null +++ b/stubs/android/widget/CheckBox.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class CheckBox extends CompoundButton { public CheckBox(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/CompoundButton.java b/stubs/android/widget/CompoundButton.java new file mode 100644 index 0000000..a1b04d0 --- /dev/null +++ b/stubs/android/widget/CompoundButton.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public abstract class CompoundButton extends Button { + public CompoundButton(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public void setChecked(boolean checked) { throw new RuntimeException("Stub!"); } + public boolean isChecked() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/widget/DatePicker.java b/stubs/android/widget/DatePicker.java new file mode 100644 index 0000000..31f4a6c --- /dev/null +++ b/stubs/android/widget/DatePicker.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class DatePicker extends FrameLayout { DatePicker() { super(null); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/EditText.java b/stubs/android/widget/EditText.java new file mode 100644 index 0000000..b5ca4f4 --- /dev/null +++ b/stubs/android/widget/EditText.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class EditText extends TextView { public EditText(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/FrameLayout.java b/stubs/android/widget/FrameLayout.java new file mode 100644 index 0000000..ded75e7 --- /dev/null +++ b/stubs/android/widget/FrameLayout.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class FrameLayout extends android.view.ViewGroup { public FrameLayout(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/LinearLayout.java b/stubs/android/widget/LinearLayout.java new file mode 100644 index 0000000..10dc6eb --- /dev/null +++ b/stubs/android/widget/LinearLayout.java @@ -0,0 +1,13 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class LinearLayout extends android.view.ViewGroup { + public static final int HORIZONTAL = 0; + public static final int VERTICAL = 1; + public LinearLayout(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public void setOrientation(int orientation) { throw new RuntimeException("Stub!"); } + public static class LayoutParams extends android.view.ViewGroup.MarginLayoutParams { + public LayoutParams(int width, int height) { super(width, height); throw new RuntimeException("Stub!"); } + public LayoutParams(int width, int height, float weight) { super(width, height); throw new RuntimeException("Stub!"); } + } +} diff --git a/stubs/android/widget/ListAdapter.java b/stubs/android/widget/ListAdapter.java new file mode 100644 index 0000000..ca5d5f4 --- /dev/null +++ b/stubs/android/widget/ListAdapter.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public interface ListAdapter extends Adapter { boolean areAllItemsEnabled(); boolean isEnabled(int position); } diff --git a/stubs/android/widget/ListView.java b/stubs/android/widget/ListView.java new file mode 100644 index 0000000..73e6929 --- /dev/null +++ b/stubs/android/widget/ListView.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class ListView extends AbsListView { + public ListView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public void setAdapter(ListAdapter adapter) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/widget/ScrollView.java b/stubs/android/widget/ScrollView.java new file mode 100644 index 0000000..99bd98c --- /dev/null +++ b/stubs/android/widget/ScrollView.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class ScrollView extends FrameLayout { public ScrollView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/SpinnerAdapter.java b/stubs/android/widget/SpinnerAdapter.java new file mode 100644 index 0000000..c81af7f --- /dev/null +++ b/stubs/android/widget/SpinnerAdapter.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public interface SpinnerAdapter extends Adapter {} diff --git a/stubs/android/widget/TextView.java b/stubs/android/widget/TextView.java new file mode 100644 index 0000000..f2b11eb --- /dev/null +++ b/stubs/android/widget/TextView.java @@ -0,0 +1,14 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class TextView extends android.view.View { + public TextView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } + public final void setText(CharSequence text) { throw new RuntimeException("Stub!"); } + public CharSequence getText() { throw new RuntimeException("Stub!"); } + public void setTextSize(float size) { throw new RuntimeException("Stub!"); } + public void setTextColor(int color) { throw new RuntimeException("Stub!"); } + public final void setHint(CharSequence hint) { throw new RuntimeException("Stub!"); } + public void setInputType(int type) { throw new RuntimeException("Stub!"); } + public void setSingleLine(boolean singleLine) { throw new RuntimeException("Stub!"); } + public void setMinLines(int minlines) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/widget/TimePicker.java b/stubs/android/widget/TimePicker.java new file mode 100644 index 0000000..f9dd23c --- /dev/null +++ b/stubs/android/widget/TimePicker.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class TimePicker extends FrameLayout { TimePicker() { super(null); throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/Toast.java b/stubs/android/widget/Toast.java new file mode 100644 index 0000000..a7513ed --- /dev/null +++ b/stubs/android/widget/Toast.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.widget; + +public class Toast { + Toast() { throw new RuntimeException("Stub!"); } + public static Toast makeText(android.content.Context context, CharSequence text, int duration) { throw new RuntimeException("Stub!"); } + public void show() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/Header.java b/stubs/org/apache/http/Header.java new file mode 100644 index 0000000..333c39f --- /dev/null +++ b/stubs/org/apache/http/Header.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface Header { String getValue(); } diff --git a/stubs/org/apache/http/HttpEntity.java b/stubs/org/apache/http/HttpEntity.java new file mode 100644 index 0000000..2e3dfc9 --- /dev/null +++ b/stubs/org/apache/http/HttpEntity.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface HttpEntity { java.io.InputStream getContent() throws java.io.IOException; void consumeContent() throws java.io.IOException; } diff --git a/stubs/org/apache/http/HttpMessage.java b/stubs/org/apache/http/HttpMessage.java new file mode 100644 index 0000000..1862590 --- /dev/null +++ b/stubs/org/apache/http/HttpMessage.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface HttpMessage { void addHeader(String name, String value); Header getFirstHeader(String name); } diff --git a/stubs/org/apache/http/HttpRequest.java b/stubs/org/apache/http/HttpRequest.java new file mode 100644 index 0000000..d19c44d --- /dev/null +++ b/stubs/org/apache/http/HttpRequest.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface HttpRequest extends HttpMessage {} diff --git a/stubs/org/apache/http/HttpResponse.java b/stubs/org/apache/http/HttpResponse.java new file mode 100644 index 0000000..325f961 --- /dev/null +++ b/stubs/org/apache/http/HttpResponse.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface HttpResponse extends HttpMessage { StatusLine getStatusLine(); HttpEntity getEntity(); } diff --git a/stubs/org/apache/http/StatusLine.java b/stubs/org/apache/http/StatusLine.java new file mode 100644 index 0000000..e528359 --- /dev/null +++ b/stubs/org/apache/http/StatusLine.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http; + +public interface StatusLine { int getStatusCode(); } diff --git a/stubs/org/apache/http/client/HttpClient.java b/stubs/org/apache/http/client/HttpClient.java new file mode 100644 index 0000000..e83c332 --- /dev/null +++ b/stubs/org/apache/http/client/HttpClient.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.client; + +public interface HttpClient { + org.apache.http.HttpResponse execute(org.apache.http.client.methods.HttpUriRequest request) throws java.io.IOException; + org.apache.http.conn.ClientConnectionManager getConnectionManager(); +} diff --git a/stubs/org/apache/http/client/methods/HttpEntityEnclosingRequestBase.java b/stubs/org/apache/http/client/methods/HttpEntityEnclosingRequestBase.java new file mode 100644 index 0000000..1702d8c --- /dev/null +++ b/stubs/org/apache/http/client/methods/HttpEntityEnclosingRequestBase.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.client.methods; + +public abstract class HttpEntityEnclosingRequestBase extends HttpRequestBase { + public HttpEntityEnclosingRequestBase() { throw new RuntimeException("Stub!"); } + public void setEntity(org.apache.http.HttpEntity entity) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/client/methods/HttpRequestBase.java b/stubs/org/apache/http/client/methods/HttpRequestBase.java new file mode 100644 index 0000000..2510690 --- /dev/null +++ b/stubs/org/apache/http/client/methods/HttpRequestBase.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.client.methods; + +public abstract class HttpRequestBase extends org.apache.http.message.AbstractHttpMessage implements HttpUriRequest { + public HttpRequestBase() { throw new RuntimeException("Stub!"); } + public abstract String getMethod(); + public void setURI(java.net.URI uri) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/client/methods/HttpUriRequest.java b/stubs/org/apache/http/client/methods/HttpUriRequest.java new file mode 100644 index 0000000..802678a --- /dev/null +++ b/stubs/org/apache/http/client/methods/HttpUriRequest.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.client.methods; + +public interface HttpUriRequest extends org.apache.http.HttpRequest {} diff --git a/stubs/org/apache/http/conn/ClientConnectionManager.java b/stubs/org/apache/http/conn/ClientConnectionManager.java new file mode 100644 index 0000000..5d001f4 --- /dev/null +++ b/stubs/org/apache/http/conn/ClientConnectionManager.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn; + +public interface ClientConnectionManager { void shutdown(); } diff --git a/stubs/org/apache/http/conn/scheme/LayeredSocketFactory.java b/stubs/org/apache/http/conn/scheme/LayeredSocketFactory.java new file mode 100644 index 0000000..444d3d7 --- /dev/null +++ b/stubs/org/apache/http/conn/scheme/LayeredSocketFactory.java @@ -0,0 +1,6 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn.scheme; + +public interface LayeredSocketFactory extends SocketFactory { + java.net.Socket createSocket(java.net.Socket socket, String host, int port, boolean autoClose) throws java.io.IOException; +} diff --git a/stubs/org/apache/http/conn/scheme/PlainSocketFactory.java b/stubs/org/apache/http/conn/scheme/PlainSocketFactory.java new file mode 100644 index 0000000..46d032c --- /dev/null +++ b/stubs/org/apache/http/conn/scheme/PlainSocketFactory.java @@ -0,0 +1,10 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn.scheme; + +public final class PlainSocketFactory implements SocketFactory { + PlainSocketFactory() { throw new RuntimeException("Stub!"); } + public static PlainSocketFactory getSocketFactory() { throw new RuntimeException("Stub!"); } + public java.net.Socket createSocket() { throw new RuntimeException("Stub!"); } + public java.net.Socket connectSocket(java.net.Socket sock, String host, int port, java.net.InetAddress localAddress, int localPort, org.apache.http.params.HttpParams params) { throw new RuntimeException("Stub!"); } + public final boolean isSecure(java.net.Socket sock) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/conn/scheme/Scheme.java b/stubs/org/apache/http/conn/scheme/Scheme.java new file mode 100644 index 0000000..2320b20 --- /dev/null +++ b/stubs/org/apache/http/conn/scheme/Scheme.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn.scheme; + +public final class Scheme { public Scheme(String name, SocketFactory factory, int port) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/org/apache/http/conn/scheme/SchemeRegistry.java b/stubs/org/apache/http/conn/scheme/SchemeRegistry.java new file mode 100644 index 0000000..42cdb59 --- /dev/null +++ b/stubs/org/apache/http/conn/scheme/SchemeRegistry.java @@ -0,0 +1,5 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn.scheme; + +public final class SchemeRegistry { public SchemeRegistry() { throw new RuntimeException("Stub!"); } + public final synchronized Scheme register(Scheme sch) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/org/apache/http/conn/scheme/SocketFactory.java b/stubs/org/apache/http/conn/scheme/SocketFactory.java new file mode 100644 index 0000000..187f372 --- /dev/null +++ b/stubs/org/apache/http/conn/scheme/SocketFactory.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.conn.scheme; + +public interface SocketFactory { + java.net.Socket createSocket() throws java.io.IOException; + java.net.Socket connectSocket(java.net.Socket sock, String host, int port, java.net.InetAddress localAddress, int localPort, org.apache.http.params.HttpParams params) throws java.io.IOException; + boolean isSecure(java.net.Socket sock); +} diff --git a/stubs/org/apache/http/entity/AbstractHttpEntity.java b/stubs/org/apache/http/entity/AbstractHttpEntity.java new file mode 100644 index 0000000..498bcbc --- /dev/null +++ b/stubs/org/apache/http/entity/AbstractHttpEntity.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.entity; + +public abstract class AbstractHttpEntity implements org.apache.http.HttpEntity { + protected AbstractHttpEntity() { throw new RuntimeException("Stub!"); } + public void setContentType(String ctString) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/entity/StringEntity.java b/stubs/org/apache/http/entity/StringEntity.java new file mode 100644 index 0000000..2726818 --- /dev/null +++ b/stubs/org/apache/http/entity/StringEntity.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.entity; + +public class StringEntity extends AbstractHttpEntity { + public StringEntity(String s, String charset) throws java.io.UnsupportedEncodingException { throw new RuntimeException("Stub!"); } + public java.io.InputStream getContent() throws java.io.IOException { throw new RuntimeException("Stub!"); } + public void consumeContent() throws java.io.IOException { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/impl/client/AbstractHttpClient.java b/stubs/org/apache/http/impl/client/AbstractHttpClient.java new file mode 100644 index 0000000..e1cbafe --- /dev/null +++ b/stubs/org/apache/http/impl/client/AbstractHttpClient.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.impl.client; + +public abstract class AbstractHttpClient implements org.apache.http.client.HttpClient { + protected AbstractHttpClient() { throw new RuntimeException("Stub!"); } + public final org.apache.http.HttpResponse execute(org.apache.http.client.methods.HttpUriRequest request) throws java.io.IOException { throw new RuntimeException("Stub!"); } + public final org.apache.http.conn.ClientConnectionManager getConnectionManager() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/impl/client/DefaultHttpClient.java b/stubs/org/apache/http/impl/client/DefaultHttpClient.java new file mode 100644 index 0000000..b74ac54 --- /dev/null +++ b/stubs/org/apache/http/impl/client/DefaultHttpClient.java @@ -0,0 +1,6 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.impl.client; + +public class DefaultHttpClient extends AbstractHttpClient { + public DefaultHttpClient(org.apache.http.conn.ClientConnectionManager conman, org.apache.http.params.HttpParams params) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/impl/conn/tsccm/ThreadSafeClientConnManager.java b/stubs/org/apache/http/impl/conn/tsccm/ThreadSafeClientConnManager.java new file mode 100644 index 0000000..ddf85ff --- /dev/null +++ b/stubs/org/apache/http/impl/conn/tsccm/ThreadSafeClientConnManager.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.impl.conn.tsccm; + +public class ThreadSafeClientConnManager implements org.apache.http.conn.ClientConnectionManager { + public ThreadSafeClientConnManager(org.apache.http.params.HttpParams params, org.apache.http.conn.scheme.SchemeRegistry schreg) { throw new RuntimeException("Stub!"); } + public void shutdown() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/message/AbstractHttpMessage.java b/stubs/org/apache/http/message/AbstractHttpMessage.java new file mode 100644 index 0000000..626203e --- /dev/null +++ b/stubs/org/apache/http/message/AbstractHttpMessage.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.message; + +public abstract class AbstractHttpMessage implements org.apache.http.HttpMessage { + protected AbstractHttpMessage() { throw new RuntimeException("Stub!"); } + public void addHeader(String name, String value) { throw new RuntimeException("Stub!"); } + public org.apache.http.Header getFirstHeader(String name) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/params/BasicHttpParams.java b/stubs/org/apache/http/params/BasicHttpParams.java new file mode 100644 index 0000000..f596099 --- /dev/null +++ b/stubs/org/apache/http/params/BasicHttpParams.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.params; + +public final class BasicHttpParams implements HttpParams { + public BasicHttpParams() { throw new RuntimeException("Stub!"); } + public HttpParams setBooleanParameter(String name, boolean value) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/params/HttpConnectionParams.java b/stubs/org/apache/http/params/HttpConnectionParams.java new file mode 100644 index 0000000..0d922a2 --- /dev/null +++ b/stubs/org/apache/http/params/HttpConnectionParams.java @@ -0,0 +1,11 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.params; + +public final class HttpConnectionParams { + HttpConnectionParams() { throw new RuntimeException("Stub!"); } + public static void setConnectionTimeout(HttpParams params, int timeout) { throw new RuntimeException("Stub!"); } + public static void setSoTimeout(HttpParams params, int timeout) { throw new RuntimeException("Stub!"); } + public static void setStaleCheckingEnabled(HttpParams params, boolean value) { throw new RuntimeException("Stub!"); } + public static int getConnectionTimeout(HttpParams params) { throw new RuntimeException("Stub!"); } + public static int getSoTimeout(HttpParams params) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/org/apache/http/params/HttpParams.java b/stubs/org/apache/http/params/HttpParams.java new file mode 100644 index 0000000..445ccd7 --- /dev/null +++ b/stubs/org/apache/http/params/HttpParams.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.params; + +public interface HttpParams { HttpParams setBooleanParameter(String name, boolean value); } diff --git a/stubs/org/apache/http/params/HttpProtocolParams.java b/stubs/org/apache/http/params/HttpProtocolParams.java new file mode 100644 index 0000000..6cd2320 --- /dev/null +++ b/stubs/org/apache/http/params/HttpProtocolParams.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package org.apache.http.params; + +public final class HttpProtocolParams { + HttpProtocolParams() { throw new RuntimeException("Stub!"); } + public static void setUseExpectContinue(HttpParams params, boolean b) { throw new RuntimeException("Stub!"); } + public static void setUserAgent(HttpParams params, String useragent) { throw new RuntimeException("Stub!"); } +} diff --git a/test/org/gingercal/BridgeTest.java b/test/org/gingercal/BridgeTest.java new file mode 100644 index 0000000..b22d19f --- /dev/null +++ b/test/org/gingercal/BridgeTest.java @@ -0,0 +1,99 @@ +package org.gingercal; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.UUID; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; + +import org.gingercal.Model.Cal; + +/** Talks to the legacy bridge the way Android 2.3 does: TLS 1.0, AES128-SHA, pinned cert. */ +public class BridgeTest { + static int fails; + + static void check(boolean ok, String what) { + System.out.println((ok ? " ok " : " FAIL ") + what); + if (!ok) fails++; + } + + static class GingerbreadHttp implements Http { + final Tls.PinTrust trust; + final HttpClient c; + + GingerbreadHttp(String pin) throws Exception { + trust = new Tls.PinTrust(pin); + SSLContext ctx = SSLContext.getInstance("TLSv1"); + ctx.init(null, new TrustManager[] {trust}, null); + SSLParameters p = new SSLParameters(new String[] {"TLS_RSA_WITH_AES_128_CBC_SHA"}, new String[] {"TLSv1"}); + c = HttpClient.newBuilder().sslContext(ctx).sslParameters(p).build(); + } + + public Resp exec(String method, String url, String[] h, String body, String ct) throws IOException { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method(method, + body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + for (int i = 0; i + 1 < h.length; i += 2) b.header(h[i], h[i + 1]); + if (ct != null) b.header("Content-Type", ct); + trust.reset(); + try { + HttpResponse r = c.send(b.build(), HttpResponse.BodyHandlers.ofString()); + Resp o = new Resp(); + o.code = r.statusCode(); + o.body = r.body(); + o.etag = r.headers().firstValue("ETag").orElse(null); + o.location = r.headers().firstValue("Location").orElse(null); + return o; + } catch (IOException e) { + throw trust.translate(e); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + public void close() {} + } + + public static void main(String[] a) throws Exception { + String url = "https://127.0.0.1:8443"; + String expected = a[0]; + // 1. first contact: not trusted -> app shows fingerprint + try { + new Dav(new GingerbreadHttp(""), url, "xose", "secret").calendarHome(); + check(false, "untrusted cert reported"); + } catch (DavException.UntrustedCert u) { + check(u.fingerprint.equals(expected) && !u.pinMismatch, "untrusted cert reported with fingerprint " + u.fingerprint.substring(0, 11) + "..."); + } + // 2. wrong pin -> mismatch warning + try { + new Dav(new GingerbreadHttp(expected.replace('1', '2')), url, "xose", "secret").calendarHome(); + check(false, "pin mismatch reported"); + } catch (DavException.UntrustedCert u) { + check(u.pinMismatch, "pin mismatch reported"); + } + // 3. pinned: full flow through the bridge + Dav dav = new Dav(new GingerbreadHttp(expected), url, "xose", "secret"); + String home = dav.calendarHome(); + check(home.equals(url + "/remote.php/dav/xose/"), "discovered home via bridge: " + home); + List cals = dav.calendars(home); + check(cals.size() == 2, "calendars via bridge: " + cals.size()); + CoreTest.MemStore st = new CoreTest.MemStore(); + Syncer.Result r = Syncer.sync(dav, st, System.currentTimeMillis(), 1, 60, false); + check(r.events > 0, "sync via bridge: " + r.summary()); + String uid = UUID.randomUUID().toString(); + long s = ICal.startOfDay(System.currentTimeMillis()) + 2 * ICal.DAY + 9 * ICal.HOUR; + Cal w = cals.get(0); + dav.create(w.href, uid, ICal.newEvent(uid, "Via bridge", "", "", s, s + ICal.HOUR, false, 10)); + String[] got = dav.get(w.href + uid + ".ics"); + check(ICal.parse(got[0]).get(0).summary.equals("Via bridge"), "create+get via bridge"); + dav.delete(w.href + uid + ".ics", got[1]); + check(true, "delete via bridge"); + System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); + System.exit(fails == 0 ? 0 : 1); + } +} diff --git a/test/org/gingercal/CoreTest.java b/test/org/gingercal/CoreTest.java new file mode 100644 index 0000000..cb59407 --- /dev/null +++ b/test/org/gingercal/CoreTest.java @@ -0,0 +1,174 @@ +package org.gingercal; + +import java.util.*; + +import org.gingercal.Model.Cal; +import org.gingercal.Model.Ev; + +/** End-to-end test of discovery, sync, create/edit/delete against a local CalDAV server. */ +public class CoreTest { + static int fails = 0; + + static void check(boolean ok, String what) { + System.out.println((ok ? " ok " : " FAIL ") + what); + if (!ok) fails++; + } + + static class MemStore implements Model.Store { + Map cals = new LinkedHashMap<>(); + Map> evs = new HashMap<>(); + public Map calendars() { Map m = new LinkedHashMap<>(); for (Cal c : cals.values()) { Cal x = new Cal(); x.href=c.href; x.name=c.name; x.enabled=c.enabled; x.ctag=c.ctag; x.synced=c.synced; m.put(c.href, x);} return m; } + public void saveCalendar(Cal c) { cals.put(c.href, c); } + public void deleteCalendar(String h) { cals.remove(h); evs.remove(h); } + public void replaceEvents(String h, List e) { evs.put(h, e); } + List all() { List a = new ArrayList<>(); for (List l : evs.values()) a.addAll(l); a.sort(Comparator.comparingLong(e -> e.start)); return a; } + } + + static String day(int offset) { return ICal.date(ICal.addDays(System.currentTimeMillis(), offset)); } + + public static void main(String[] a) throws Exception { + String base = a.length > 0 ? a[0] : "http://127.0.0.1:5232/"; + JdkHttp raw = new JdkHttp(); + String auth = "Basic " + B64.encode("xose:secret".getBytes("UTF-8")); + String[] h = {"Authorization", auth}; + // --- seed two calendars and some tricky events + for (String[] c : new String[][] {{"work", "Work stuff", "#E9322DFF"}, {"home", "Home", "#0082C9"}}) { + raw.exec("DELETE", base + "xose/" + c[0] + "/", h, null, null); + Http.Resp r = raw.exec("MKCALENDAR", base + "xose/" + c[0] + "/", h, + "" + c[1] + "" + c[2] + "", + "application/xml"); + check(r.code == 201, "MKCALENDAR " + c[0] + " -> " + r.code); + } + String tz = "BEGIN:VTIMEZONE\r\nTZID:Europe/Madrid\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n"; + String timed = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:timed-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(1) + "T100000\r\nDTEND;TZID=Europe/Madrid:" + day(1) + "T113000\r\nSUMMARY:Reunión\\, equipo ñ\r\nLOCATION:Vigo\\; sala 2\r\nDESCRIPTION:Line one\\nLine two with a very long text that definitely needs folding because it is over seventy-five octets long\r\nATTENDEE;CN=\"Doe, Jane\":mailto:jane@example.com\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:keep me\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + String allday = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nBEGIN:VEVENT\r\nUID:allday-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;VALUE=DATE:" + day(2) + "\r\nDTEND;VALUE=DATE:" + day(4) + "\r\nSUMMARY:Holidays\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + String weekly = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n" + tz + "BEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Madrid:" + day(-14) + "T090000\r\nDTEND;TZID=Europe/Madrid:" + day(-14) + "T093000\r\nRRULE:FREQ=WEEKLY;COUNT=10\r\nEXDATE;TZID=Europe/Madrid:" + day(7) + "T090000\r\nSUMMARY:Standup\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;TZID=Europe/Madrid:" + day(14) + "T090000\r\nDTSTART;TZID=Europe/Madrid:" + day(14) + "T120000\r\nDTEND;TZID=Europe/Madrid:" + day(14) + "T123000\r\nSUMMARY:Standup (moved)\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + check(raw.exec("PUT", base + "xose/work/timed-1.ics", h, timed, "text/calendar").code == 201, "seed timed"); + check(raw.exec("PUT", base + "xose/home/allday-1.ics", h, allday, "text/calendar").code == 201, "seed all-day"); + check(raw.exec("PUT", base + "xose/work/weekly-1.ics", h, weekly, "text/calendar").code == 201, "seed weekly"); + + // --- discovery + JdkHttp http = new JdkHttp(); + Dav dav = new Dav(http, base.replace("http://", "http://"), "xose", "secret"); + String home = dav.calendarHome(); + System.out.println(" home = " + home); + check(home.endsWith("/xose/"), "calendar home discovered via fallbacks"); + List cals = dav.calendars(home); + check(cals.size() == 2, "two calendars listed (" + cals.size() + ")"); + for (Cal c : cals) System.out.printf(" cal %s name=%s color=%08X writable=%s ctag=%s%n", c.href, c.name, c.color, c.writable, c.ctag); + Cal work = null; + for (Cal c : cals) if (c.name.equals("Work stuff")) work = c; + check(work != null && work.color == 0xFFE9322D, "displayname + colour parsed"); + + // --- bad password + try { + new Dav(new JdkHttp(), base, "xose", "wrong").calendarHome(); + check(false, "401 detected"); + } catch (DavException e) { + check(e.code == 401, "401 detected: " + e.getMessage().substring(0, 30)); + } + + // --- sync + MemStore st = new MemStore(); + long now = System.currentTimeMillis(); + Syncer.Result r = Syncer.sync(dav, st, now, 7, 30, false); + System.out.println(" sync: " + r.summary()); + List all = st.all(); + for (Ev e : all) System.out.printf(" %-26s %s -> %s allDay=%s rec=%s%n", e.title, new Date(e.start), new Date(e.end), e.allDay, e.recurring); + Ev t1 = find(all, "Reunión, equipo ñ"); + check(t1 != null, "escaped summary decoded"); + Calendar exp = Calendar.getInstance(TimeZone.getTimeZone("Europe/Madrid")); + exp.setTimeInMillis(ICal.addDays(ICal.startOfDay(now), 1)); + exp.set(Calendar.HOUR_OF_DAY, 10); + check(t1 != null && t1.start == exp.getTimeInMillis() && t1.end - t1.start == 90 * 60000, "TZID time correct"); + check(t1 != null && t1.location.equals("Vigo; sala 2") && t1.descr.startsWith("Line one\nLine two"), "location/description unescaped"); + Ev ad = find(all, "Holidays"); + check(ad != null && ad.allDay && ad.end == ICal.addDays(ad.start, 2), "all-day 2 days"); + int standups = 0, moved = 0; + for (Ev e : all) { if (e.title.equals("Standup")) standups++; if (e.title.equals("Standup (moved)")) moved++; } + // window: -7d..+30d; weekly from -14d count 10 -> days -14,-7,0,7(ex),14(moved),21,28,35..: in window -7,0,21,28 + moved + System.out.println(" standups=" + standups + " moved=" + moved); + check(standups == 4 && moved == 1, "recurrence expanded with EXDATE and override"); + for (Ev e : all) if (e.title.startsWith("Standup")) check(e.recurring, "instance flagged recurring"); + + // --- ctag skip + int before = http.requests; + Syncer.Result r2 = Syncer.sync(dav, st, now, 7, 30, false); + check(r2.skipped == 2 && r2.fetched == 0, "second sync skips unchanged calendars (" + r2.summary() + ", " + (http.requests - before) + " requests)"); + + // --- create + String uid = UUID.randomUUID().toString(); + long s = ICal.addDays(ICal.startOfDay(now), 3) + 15 * ICal.HOUR; + dav.create(work.href, uid, ICal.newEvent(uid, "Dentist, 2nd floor", "Rúa do Príncipe", "Bring card\nand ID", s, s + ICal.HOUR, false, 15)); + String[] got = dav.get(work.href + uid + ".ics"); + ICal.VEvent ve = ICal.parse(got[0]).get(0); + check(ve.summary.equals("Dentist, 2nd floor") && ve.start == s && ve.alarmMin == 15 && ve.description.equals("Bring card\nand ID"), "created event round-trips"); + try { dav.create(work.href, uid, ICal.newEvent(uid, "dup", "", "", s, s, false, -1)); check(false, "If-None-Match prevents overwrite"); } + catch (DavException e) { check(e.code == 412, "If-None-Match prevents overwrite"); } + + // --- edit the timed event (keeps attendee + its alarm) + String[] t = dav.get(work.href + "timed-1.ics"); + check(!ICal.isRecurring(t[0]), "timed is not recurring"); + check(ICal.isRecurring(dav.get(work.href + "weekly-1.ics")[0]), "weekly is recurring"); + String mod = ICal.modify(t[0], "Reunión movida", "", "nueva", t1.start + ICal.HOUR, t1.end + ICal.HOUR, false, -2); + dav.update(work.href + "timed-1.ics", t[1], mod); + String[] t2 = dav.get(work.href + "timed-1.ics"); + ICal.VEvent m = ICal.parse(t2[0]).get(0); + check(m.summary.equals("Reunión movida") && m.start == t1.start + ICal.HOUR && m.location.equals("") && m.sequence == 1, "edit applied"); + check(t2[0].contains("ATTENDEE") && m.alarmMin == 30 && t2[0].contains("keep me"), "unrelated props + alarm kept"); + try { dav.update(work.href + "timed-1.ics", t[1], mod); check(false, "stale etag rejected"); } + catch (DavException e) { check(e.code == 412, "stale etag rejected"); } + String mod2 = ICal.modify(t2[0], "Reunión movida", "", "nueva", t1.start, t1.end, false, 10); + dav.update(work.href + "timed-1.ics", t2[1], mod2); + ICal.VEvent m2 = ICal.parse(dav.get(work.href + "timed-1.ics")[0]).get(0); + check(m2.alarmMin == 10 && m2.sequence == 2, "alarm replaced"); + + // --- all-day create + String uid2 = UUID.randomUUID().toString(); + long d0 = ICal.addDays(ICal.startOfDay(now), 5); + dav.create(cals.get(0).href, uid2, ICal.newEvent(uid2, "Trip", "", "", d0, ICal.addDays(d0, 1), true, -1)); + + // --- delete + dav.delete(work.href + uid + ".ics", null); + try { dav.get(work.href + uid + ".ics"); check(false, "deleted"); } catch (DavException e) { check(e.code == 404, "deleted"); } + + Syncer.Result r3 = Syncer.sync(dav, st, now, 7, 30, false); + check(find(st.all(), "Trip") != null && find(st.all(), "Trip").allDay, "new all-day event synced (" + r3.summary() + ")"); + check(find(st.all(), "Dentist, 2nd floor") == null, "deleted event gone after sync"); + + // --- disabling a calendar clears its events + st.cals.get(work.href).enabled = false; + Syncer.sync(dav, st, now, 7, 30, false); + check(find(st.all(), "Standup") == null, "disabled calendar emptied"); + + // --- client-side expansion when a server ignores + final String unexpanded = weekly.replace("&", "&").replace("<", "<"); + Http fake = new Http() { + public Resp exec(String m, String u, String[] hh, String b, String ct) { + Resp rr = new Resp(); rr.code = 207; + rr.body = "/x/w.ics\"1\"" + unexpanded + "HTTP/1.1 200 OK"; + return rr; + } + public void close() {} + }; + Cal fc = new Cal(); fc.href = "http://x/x/"; + List fe = new Dav(fake, "http://x/", "u", "p").events(fc, ICal.addDays(ICal.startOfDay(now), -7), ICal.addDays(ICal.startOfDay(now), 31)); + int fs = 0, fm = 0; + for (Ev e : fe) { if (e.title.equals("Standup")) fs++; if (e.title.equals("Standup (moved)")) fm++; } + check(fs == 4 && fm == 1, "client-side RRULE/EXDATE/override expansion (" + fs + "+" + fm + ")"); + + // --- folding + String longLine = ICal.fold("DESCRIPTION:" + "ñ".repeat(60)); + boolean okFold = true; + for (String l : longLine.split("\r\n")) okFold &= l.getBytes("UTF-8").length <= 75; + check(okFold && ICal.unfold(longLine).get(0).equals("DESCRIPTION:" + "ñ".repeat(60)), "UTF-8 safe folding"); + + System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); + System.exit(fails == 0 ? 0 : 1); + } + + static Ev find(List l, String title) { + for (Ev e : l) if (e.title.equals(title)) return e; + return null; + } +} diff --git a/test/org/gingercal/JdkHttp.java b/test/org/gingercal/JdkHttp.java new file mode 100644 index 0000000..911b5c7 --- /dev/null +++ b/test/org/gingercal/JdkHttp.java @@ -0,0 +1,43 @@ +package org.gingercal; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** Desktop-only transport used to test the CalDAV core against a real server. */ +public class JdkHttp implements Http { + final HttpClient c = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(Duration.ofSeconds(10)).build(); + public int requests; + + public Resp exec(String method, String url, String[] headers, String body, String contentType) + throws IOException { + requests++; + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)) + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body)); + for (int i = 0; i + 1 < headers.length; i += 2) b.header(headers[i], headers[i + 1]); + if (contentType != null) b.header("Content-Type", contentType); + try { + HttpResponse r; + try { + r = c.send(b.build(), HttpResponse.BodyHandlers.ofString()); + } catch (IOException stale) { // test server drops idle keep-alive sockets + r = c.send(b.build(), HttpResponse.BodyHandlers.ofString()); + } + Resp out = new Resp(); + out.code = r.statusCode(); + out.body = r.body(); + out.etag = r.headers().firstValue("ETag").orElse(null); + out.location = r.headers().firstValue("Location").orElse(null); + return out; + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + public void close() {} +} diff --git a/test/org/gingercal/RedirectTest.java b/test/org/gingercal/RedirectTest.java new file mode 100644 index 0000000..51c834b --- /dev/null +++ b/test/org/gingercal/RedirectTest.java @@ -0,0 +1,88 @@ +package org.gingercal; + +import java.util.ArrayList; +import java.util.List; + +/** + * Forced-HTTPS / HSTS scenarios. Unit part uses a scripted fake server; with an argument it + * also runs live against a phone-profile client (see run in README). + */ +public class RedirectTest { + static int fails; + + static void check(boolean ok, String what) { + System.out.println((ok ? " ok " : " FAIL ") + what); + if (!ok) fails++; + } + + /** Answers each request from a script of {status, location} pairs; then 207 multistatus. */ + static class Scripted implements Http { + final String[][] script; + final List urls = new ArrayList(); + + Scripted(String[][] script) { + this.script = script; + } + + public Resp exec(String m, String url, String[] h, String body, String ct) { + urls.add(url); + Resp r = new Resp(); + int i = urls.size() - 1; + if (i < script.length) { + r.code = Integer.parseInt(script[i][0]); + r.location = script[i][1]; + } else { + r.code = 207; + r.body = "/remote.php/dav/" + + "/remote.php/dav/principals/users/x/" + + "HTTP/1.1 200 OK" + + ""; + } + return r; + } + + public void close() {} + } + + static String expectError(String url, String[][] script) { + Scripted s = new Scripted(script); + try { + new Dav(s, url, "u", "p").multistatus("PROPFIND", url + "/remote.php/dav/", "0", ""); + return "no error after " + s.urls; + } catch (DavException e) { + return (e.code == DavException.REDIRECT ? "REDIRECT: " : "code " + e.code + ": ") + e.getMessage(); + } catch (Exception e) { + return e.toString(); + } + } + + public static void main(String[] a) throws Exception { + // 1. LAN http:// straight at a Nextcloud that forces HTTPS + String m = expectError("http://192.168.1.20", new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}}); + check(m.startsWith("REDIRECT") && m.contains("forces HTTPS") && m.contains("https://cloud.example.com"), "http->https upgrade explained: " + m.substring(0, 70)); + // 2. bridge passes Nextcloud's absolute public URL through + m = expectError("https://vps.example.com:8443", new String[][] {{"302", "https://cloud.example.com/remote.php/dav/"}}); + check(m.startsWith("REDIRECT") && m.contains("proxy_redirect"), "cross-host redirect explained"); + // 3. bridge with http upstream to a forced-HTTPS Nextcloud: same URL again + m = expectError("https://vps.example.com:8443", new String[][] {{"301", "/remote.php/dav/"}, {"301", "/remote.php/dav/"}}); + check(m.startsWith("REDIRECT") && m.contains("loop") && m.contains("proxy_pass https://"), "loop explained after 1 retry"); + // 4. default port written out is the same server + Scripted s = new Scripted(new String[][] {{"301", "https://vps.example.com:443/remote.php/dav/"}}); + new Dav(s, "https://vps.example.com", "u", "p").multistatus("PROPFIND", "https://vps.example.com/remote.php/dav/", "0", ""); + check(s.urls.size() == 2, "explicit :443 treated as same server"); + // 5. legit same-server redirect still followed + s = new Scripted(new String[][] {{"301", "/remote.php/dav/"}}); + new Dav(s, "https://h:8443", "u", "p").multistatus("PROPFIND", "https://h:8443/remote.php/dav", "0", ""); + check(s.urls.size() == 2 && s.urls.get(1).equals("https://h:8443/remote.php/dav/"), "same-server redirect followed"); + // 6. discovery stops at a redirect problem instead of trying other paths + s = new Scripted(new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}}); + try { + new Dav(s, "http://192.168.1.20", "u", "p").calendarHome(); + check(false, "discovery reports forced HTTPS"); + } catch (DavException e) { + check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports forced HTTPS immediately"); + } + System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); + System.exit(fails == 0 ? 0 : 1); + } +} diff --git a/tools/check_api.py b/tools/check_api.py new file mode 100644 index 0000000..328aaad --- /dev/null +++ b/tools/check_api.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Checks every Android / Apache HttpClient method and field the dex references against +published API declarations (NativeScript's android-platform-*.d.ts). Catches stub mistakes +(wrong name, arity, parameter or return kind) that would otherwise surface as +NoSuchMethodError on the phone. usage: check_api.py classes.dex android-platform-17.d.ts""" +import re +import sys +from loguru import logger + +logger.remove() +from androguard.core.dex import DEX + +dex_path, dts_path = sys.argv[1], sys.argv[2] + +# ----------------------------------------------------------- parse the .d.ts +classes = {} # fqcn -> {"supers": [...], "methods": {name: [(params, ret)]}, "fields": set()} +stack = [] # (kind, name, depth_at_open) +depth = 0 +cur = None +cls_re = re.compile(r"export (?:abstract )?class (\w+)(?:<[^{]*?>)?(?:\s+extends ([\w.]+)(?:<.*?>)?)?(?:\s+implements (.+?))?\s*\{") +mod_re = re.compile(r"(?:declare|export) module ([\w.]+) \{") +meth_re = re.compile(r"^\s*(?:public |protected )?(?:static )?(\w+)(?:<[^(]*>)?\((.*)\): (.+);\s*$") +field_re = re.compile(r"^\s*public (?:static )?(\w+): (.+);\s*$") + + +def strip_generics(t): + out, d = "", 0 + for ch in t: + if ch == "<": + d += 1 + elif ch == ">": + d -= 1 + elif d == 0: + out += ch + return out.strip() + + +for line in open(dts_path, encoding="utf-8"): + m = mod_re.search(line) + c = cls_re.search(line) + if c: + name = ".".join([s[1] for s in stack if s[0] in ("mod", "cls")] + [c.group(1)]) + supers = [] + if c.group(2): + supers.append(c.group(2)) + if c.group(3): + supers += [strip_generics(s) for s in strip_generics(c.group(3)).split(",")] + classes.setdefault(name, {"supers": [], "methods": {}, "fields": set()})["supers"] += supers + stack.append(("cls", c.group(1), depth)) + cur = classes[name] + elif m: + for part in m.group(1).split("."): + stack.append(("mod", part, depth)) + elif cur is not None and stack and stack[-1][0] == "cls": + mm = meth_re.match(line) + if mm and mm.group(1) not in ("constructor",): + params = [p for p in mm.group(2).split(", ") if p] if mm.group(2).strip() else [] + ptypes = [strip_generics(p.split(": ", 1)[1]) if ": " in p else "any" for p in params] + cur["methods"].setdefault(mm.group(1), []).append((ptypes, strip_generics(mm.group(3)))) + elif "public constructor(" in line and "implementation" not in line: + inner = line.split("public constructor(", 1)[1].rsplit(");", 1)[0] + params = [p for p in inner.split(", ") if p] if inner.strip() else [] + cur["methods"].setdefault("", []).append( + ([strip_generics(p.split(": ", 1)[1]) for p in params], "void")) + else: + fm = field_re.match(line) + if fm: + cur["fields"].add(fm.group(1)) + depth += line.count("{") - line.count("}") + while stack and depth <= stack[-1][2]: + stack.pop() + cur = classes.get(".".join(s[1] for s in stack if s[0] in ("mod", "cls"))) if stack and stack[-1][0] == "cls" else None + +# ------------------------------------------------------------- type mapping +NUM = {"I", "J", "F", "D", "S", "B", "C"} + + +def ts_of(desc): + if desc in NUM: + return "number" + if desc == "Z": + return "boolean" + if desc == "V": + return "void" + if desc.startswith("["): + return "native.Array<%s>" % ts_of(desc[1:]) + n = desc[1:-1].replace("/", ".").replace("$", ".") + if n in ("java.lang.String", "java.lang.CharSequence"): + return "string" + return n + + +def compatible(java, ts): + ts = ts.replace(" ", "") + if ts in ("any", "any[]"): + return True + if java.startswith("native.Array"): + return ts.startswith("native.Array") or ts.endswith("[]") + if java == "java.lang.Object": + return True + return java == ts or (java == "string" and ts == "string") + + +def lookup(cls, name, params, ret, seen=None): + seen = seen or set() + if cls in seen or cls not in classes: + return False + seen.add(cls) + for ptypes, r in classes[cls]["methods"].get(name, []): + if len(ptypes) == len(params) and all(compatible(j, t) for j, t in zip(params, ptypes)): + if name == "" or compatible(ret, r) or r == "any": + return True + return any(lookup(s, name, params, ret, seen) for s in classes[cls]["supers"]) + + +def has_field(cls, name, seen=None): + seen = seen or set() + if cls in seen or cls not in classes: + return False + seen.add(cls) + return name in classes[cls]["fields"] or any(has_field(s, name, seen) for s in classes[cls]["supers"]) + + +# ------------------------------------------------------------- check the dex +raw = open(dex_path, "rb").read() +if raw[:2] == b"PK": + import zipfile + raw = zipfile.ZipFile(dex_path).read("classes.dex") +d = DEX(raw) +own = {c.get_name() for c in d.get_classes()} +bad, checked, seen = [], 0, set() +for mid in d.get_methods_id_item().get_obj(): + cname = mid.get_class_name() + if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own: + continue + desc = mid.get_descriptor().replace(" ", "") + key = (cname, mid.get_name(), desc) + if key in seen: + continue + seen.add(key) + plist = re.findall(r"\[*(?:L[^;]+;|[ZBCSIJFD])", desc[1:desc.index(")")]) + ret = desc[desc.index(")") + 1:] + cls = cname[1:-1].replace("/", ".").replace("$", ".") + checked += 1 + if not lookup(cls, mid.get_name(), [ts_of(p) for p in plist], ts_of(ret)): + bad.append("%s.%s%s" % (cls, mid.get_name(), desc)) +for fid in d.get_fields_id_item().get_obj(): + cname = fid.get_class_name() + if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own: + continue + cls = cname[1:-1].replace("/", ".").replace("$", ".") + checked += 1 + if not has_field(cls, fid.get_name()): + bad.append("field %s.%s" % (cls, fid.get_name())) +print("checked %d framework references against %d declared classes" % (checked, len(classes))) +for b in bad: + print(" NOT FOUND:", b) +sys.exit(1 if bad else 0) diff --git a/tools/mkapk.py b/tools/mkapk.py new file mode 100644 index 0000000..8294615 --- /dev/null +++ b/tools/mkapk.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Packs an APK without aapt: binary AndroidManifest.xml, a tiny resources.arsc +(icon + app name), classes.dex. Output is unsigned; build.sh signs and aligns it. + +usage: mkapk.py AndroidManifest.xml classes.dex icon.png out.apk +""" +import struct +import sys +import zipfile +import xml.etree.ElementTree as ET + +ANDROID_NS = "http://schemas.android.com/apk/res/android" + +# android.R.attr ids (stable since API 1-4) +ATTR_IDS = { + "theme": 0x01010000, "label": 0x01010001, "icon": 0x01010002, "name": 0x01010003, + "permission": 0x01010006, "enabled": 0x0101000e, "exported": 0x01010010, + "configChanges": 0x0101001f, "minSdkVersion": 0x0101020c, "versionCode": 0x0101021b, + "versionName": 0x0101021c, "windowSoftInputMode": 0x0101022b, + "targetSdkVersion": 0x01010270, "installLocation": 0x010102b7, +} +INT_ATTRS = {"versionCode", "minSdkVersion", "targetSdkVersion"} +BOOL_ATTRS = {"exported", "enabled"} +FLAGS = { + "configChanges": {"mcc": 1, "mnc": 2, "locale": 4, "touchscreen": 8, "keyboard": 0x10, + "keyboardHidden": 0x20, "navigation": 0x40, "orientation": 0x80, + "screenLayout": 0x100, "uiMode": 0x200, "fontScale": 0x40000000}, + "windowSoftInputMode": {"stateUnspecified": 0, "stateHidden": 2, "stateAlwaysHidden": 3, + "adjustResize": 0x10, "adjustPan": 0x20}, + "installLocation": {"auto": 0, "internalOnly": 1, "preferExternal": 2}, +} + +T_REF, T_STRING, T_INT_DEC, T_INT_HEX, T_BOOL = 0x01, 0x03, 0x10, 0x11, 0x12 + +# Our own resources: type id -> (type name, [(key, value)]) +RES_TYPES = [("drawable", [("icon", "res/drawable/icon.png")]), + ("string", [("app_name", "GingerCal")])] + + +def res_id(ref): + typ, key = ref.lstrip("@").split("/") + for ti, (tname, entries) in enumerate(RES_TYPES): + for ei, (k, _) in enumerate(entries): + if tname == typ and k == key: + return 0x7F000000 | ((ti + 1) << 16) | ei + raise SystemExit("unknown resource " + ref) + + +# ------------------------------------------------------------- string pool + +def string_pool(strings): + """UTF-16 ResStringPool (understood by every Android version).""" + offsets, data = [], b"" + for s in strings: + offsets.append(len(data)) + u = s.encode("utf-16-le") + n = len(u) // 2 + if n > 0x7FFF: + raise SystemExit("string too long") + data += struct.pack(" 1 else "icon.png", optimize=True) diff --git a/tools/strip_method_params.py b/tools/strip_method_params.py new file mode 100644 index 0000000..7145679 --- /dev/null +++ b/tools/strip_method_params.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Removes MethodParameters attributes from .class files (in place). + +JDK 21+ javac always emits them for inner-class constructors, and older d8 releases +crash on the nameless ones. They are only reflection metadata, so dropping them is safe. +usage: strip_method_params.py file.class... +""" +import struct +import sys + + +def strip(path): + d = open(path, "rb").read() + n = struct.unpack(">H", d[8:10])[0] + p, i, utf8 = 10, 1, {} + while i < n: + t = d[p] + if t == 1: + ln = struct.unpack(">H", d[p + 1:p + 3])[0] + utf8[i] = d[p + 3:p + 3 + ln] + p += 3 + ln + elif t in (3, 4, 9, 10, 11, 12, 17, 18): + p += 5 + elif t in (5, 6): + p += 9 + i += 1 + elif t in (7, 8, 16, 19, 20): + p += 3 + elif t == 15: + p += 4 + else: + raise ValueError("bad constant tag %d in %s" % (t, path)) + i += 1 + out = bytearray(d[:p]) + p_access = p + ifaces = struct.unpack(">H", d[p + 6:p + 8])[0] + p += 8 + 2 * ifaces + out += d[p_access:p] + + def members(p, drop): + count = struct.unpack(">H", d[p:p + 2])[0] + buf = bytearray(d[p:p + 2]) + p += 2 + for _ in range(count): + buf += d[p:p + 6] + acount = struct.unpack(">H", d[p + 6:p + 8])[0] + p += 8 + kept = [] + for _ in range(acount): + name, ln = struct.unpack(">HI", d[p:p + 6]) + if utf8.get(name) != drop: + kept.append(d[p:p + 6 + ln]) + p += 6 + ln + buf += struct.pack(">H", len(kept)) + b"".join(kept) + return p, buf + + p, fields = members(p, None) + out += fields + p, methods = members(p, b"MethodParameters") + out += methods + out += d[p:] + if bytes(out) != d: + open(path, "wb").write(bytes(out)) + return 1 + return 0 + + +changed = sum(strip(f) for f in sys.argv[1:]) +print("stripped MethodParameters from %d class files" % changed) diff --git a/tools/zipalign.py b/tools/zipalign.py new file mode 100644 index 0000000..b769fc5 --- /dev/null +++ b/tools/zipalign.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Minimal zipalign: stored entries start on 4-byte boundaries. Keeps v1 (JAR) signatures valid, +since those cover file contents, not the zip layout. usage: zipalign.py in.apk out.apk""" +import sys +import zipfile + +src, dst = sys.argv[1], sys.argv[2] +with zipfile.ZipFile(src) as zin, open(dst, "wb") as f: + with zipfile.ZipFile(f, "w") as zout: + for info in zin.infolist(): + data = zin.read(info.filename) + out = zipfile.ZipInfo(info.filename, info.date_time) + out.compress_type = info.compress_type + out.external_attr = info.external_attr + if info.compress_type == zipfile.ZIP_STORED: + header_end = f.tell() + 30 + len(info.filename.encode("utf-8")) + out.extra = b"\0" * ((4 - header_end % 4) % 4) + zout.writestr(out, data) +with zipfile.ZipFile(dst) as z: + for info in z.infolist(): + if info.compress_type == zipfile.ZIP_STORED: + start = info.header_offset + 30 + len(info.filename.encode("utf-8")) + len(info.extra) + assert start % 4 == 0, info.filename +print("aligned", dst)