Import GingerCal 1.0 source

This commit is contained in:
xocko committed 2026-10-15 16:35:05 +02:00
commit 01f49852ab
116 files changed
+5079

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
build/
out/
bridge/certs/
.DS_Store
._*
*.apk
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.gingercal"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<application android:label="@string/app_name" android:icon="@drawable/icon">
<activity android:name=".MainActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".SettingsActivity" android:label="Settings"
android:configChanges="keyboardHidden|orientation"
android:windowSoftInputMode="stateHidden|adjustResize" />
<activity android:name=".EditActivity" android:label="Event"
android:configChanges="keyboardHidden|orientation"
android:windowSoftInputMode="stateHidden|adjustResize" />
<activity android:name=".CalendarsActivity" android:label="Calendars"
android:configChanges="keyboardHidden|orientation" />
<service android:name=".SyncService" android:exported="false" />
<receiver android:name=".AlarmReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
+147
View File
@@ -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-<port>` 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://<your-pangolin-domain-or-IP>: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 <newt address>;`,
`real_ip_header proxy_protocol;`. Enable both sides together or every connection fails.
### Option B: home Wi-Fi only (no Pangolin)
Use `http://<nextcloud-LAN-IP>` 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.
+41
View File
@@ -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();
}
}
+177
View File
@@ -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) {
}
}
}
}
+233
View File
@@ -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");
}
}
@@ -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<Cal> cals = new ArrayList<Cal>();
final List<CheckBox> boxes = new ArrayList<CheckBox>();
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<Cal> w = new ArrayList<Cal>();
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);
}
}
+193
View File
@@ -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<String, Cal> calendars() {
Map<String, Cal> m = new LinkedHashMap<String, Cal>();
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<Cal> calendarList() {
return new ArrayList<Cal>(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<Ev> 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<Ev> query(String where, String[] args) {
List<Ev> out = new ArrayList<Ev>();
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<Ev> 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<Ev> 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<Ev> 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();
}
}
}
+383
View File
@@ -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<Cal> cals = new ArrayList<Cal>();
@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<Void, Void, Object> {
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<ICal.VEvent> 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<Void, Void, Throwable> {
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();
}
}
}
+373
View File
@@ -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<Object> rows = new ArrayList<Object>();
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<Ev> 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<Cal> writableCalendars() {
List<Cal> out = new ArrayList<Cal>();
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<Void, Void, Throwable> {
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;
}
}
}
+63
View File
@@ -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<Ev> 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);
}
}
+243
View File
@@ -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<Void, Void, Object> {
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<Cal> cals = (List<Cal>) 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();
}
}
}
+64
View File
@@ -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);
}
}
+23
View File
@@ -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
+15
View File
@@ -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
+75
View File
@@ -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;
}
}
Executable
+46
View File
@@ -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
Binary file not shown.
+22
View File
@@ -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();
}
}
+386
View File
@@ -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<String, Node> props = new HashMap<String, Node>();
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<String> visited = new ArrayList<String>();
for (int hop = 0; hop < 6; hop++) {
visited.add(u);
List<String> h = new ArrayList<String>();
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<String> 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<Response> 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<Response> out = new ArrayList<Response>();
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 "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\" xmlns:cs=\"" + CS + "\" xmlns:a=\"" + A + "\">"
+ "<d:prop>" + props + "</d:prop></d:propfind>";
}
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<String> candidates = new ArrayList<String>();
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<Response> rs = multistatus("PROPFIND", c, "0", propfind("<d:current-user-principal/>"));
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<Response> rs = multistatus("PROPFIND", principal, "0", propfind("<c:calendar-home-set/>"));
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<Cal> calendars(String home) throws IOException {
List<Response> rs = multistatus("PROPFIND", home, "1", propfind(
"<d:resourcetype/><d:displayname/><a:calendar-color/><cs:getctag/>"
+ "<c:supported-calendar-component-set/><d:current-user-privilege-set/>"));
List<Cal> out = new ArrayList<Cal>();
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<Ev> events(Cal cal, long from, long to) throws IOException {
String f = ICal.utc(from), t = ICal.utc(to);
String body = "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\">"
+ "<d:prop><d:getetag/><c:calendar-data><c:expand start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:calendar-data></d:prop>"
+ "<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VEVENT\">"
+ "<c:time-range start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>";
List<Response> rs = multistatus("REPORT", cal.href, "1", body);
List<Ev> out = new ArrayList<Ev>();
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<ICal.VEvent> 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<Long, ICal.VEvent> overrides = new HashMap<Long, ICal.VEvent>();
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<ICal.VEvent> 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;
}
}
}
}
+32
View File
@@ -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;
}
}
}
+20
View File
@@ -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;
}
}
+552
View File
@@ -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<String, String> params = new HashMap<String, String>();
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<Long> exdates = new ArrayList<Long>();
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<String> unfold(String s) {
List<String> out = new ArrayList<String>();
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<String> parts = new ArrayList<String>();
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<VEvent> parse(String ics) {
List<VEvent> out = new ArrayList<VEvent>();
List<String> stack = new ArrayList<String>();
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 <C:expand>. Covers the common rules.
public static List<long[]> occurrences(VEvent e, long from, long to) {
List<long[]> out = new ArrayList<long[]>();
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<String, String> r = new HashMap<String, String>();
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<Long> starts = new ArrayList<Long>();
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<String> 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<String> 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<String> l = new ArrayList<String>();
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<VEvent> 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<String> in = unfold(ics);
List<String> out = new ArrayList<String>();
List<String> stack = new ArrayList<String>();
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<String> 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;
}
}
}
+36
View File
@@ -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<String, Cal> calendars();
void saveCalendar(Cal c);
void deleteCalendar(String href);
void replaceEvents(String calHref, List<Ev> events);
}
}
+70
View File
@@ -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<Cal> remote = dav.calendars(home);
Map<String, Cal> local = store.calendars();
Set<String> seen = new HashSet<String>();
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<Ev>());
r.synced = 0;
store.saveCalendar(r);
continue;
}
res.calendars++;
if (unchanged) {
res.skipped++;
r.ctag = old.ctag;
store.saveCalendar(r);
continue;
}
List<Ev> 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;
}
}
+112
View File
@@ -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;
}
}
}
+127
View File
@@ -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<Node> kids = new ArrayList<Node>();
public final java.util.Map<String, String> attrs = new java.util.HashMap<String, String>();
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<Node> children(String ns, String name) {
List<Node> out = new ArrayList<Node>();
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<Node> stack = new ArrayList<Node>();
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("&lt;");
else if (c == '>') b.append("&gt;");
else if (c == '&') b.append("&amp;");
else if (c == '"') b.append("&quot;");
else b.append(c);
}
return b.toString();
}
}
+15
View File
@@ -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!"); }
}
+9
View File
@@ -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!"); }
}
+16
View File
@@ -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!"); }
}
}
+7
View File
@@ -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); }
}
+7
View File
@@ -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!"); }
}
+7
View File
@@ -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);
}
+9
View File
@@ -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!"); }
}
@@ -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!"); }
}
+8
View File
@@ -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!"); }
}
+6
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
+7
View File
@@ -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); }
}
@@ -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);
}
+4
View File
@@ -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!"); } }
+15
View File
@@ -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();
}
+16
View File
@@ -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!"); }
}
@@ -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); }
}
+17
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
@@ -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();
}
}
+7
View File
@@ -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!"); }
}
+11
View File
@@ -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();
}
@@ -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!"); }
}
@@ -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!"); }
}
@@ -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!"); } }
+5
View File
@@ -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!"); } }
+10
View File
@@ -0,0 +1,10 @@
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
package android.os;
public abstract class AsyncTask<Params, Progress, Result> {
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<Params, Progress, Result> execute(Params... params) { throw new RuntimeException("Stub!"); }
}
+4
View File
@@ -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!"); } }
+14
View File
@@ -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!"); }
}
}
@@ -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!"); } }
+4
View File
@@ -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!"); } }
@@ -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!"); } }
+4
View File
@@ -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); }
+4
View File
@@ -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); }
+16
View File
@@ -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!"); }
}
+13
View File
@@ -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!"); }
}
}
+4
View File
@@ -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<ListAdapter> { public AbsListView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } }
+11
View File
@@ -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();
}
+8
View File
@@ -0,0 +1,8 @@
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
package android.widget;
public abstract class AdapterView<T extends Adapter> 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!"); }
}
+11
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
+4
View File
@@ -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!"); } }
+8
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
+4
View File
@@ -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!"); } }
+4
View File
@@ -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!"); } }
+13
View File
@@ -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!"); }
}
}
+4
View File
@@ -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); }
+7
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
+4
View File
@@ -0,0 +1,4 @@
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
package android.widget;
public interface SpinnerAdapter extends Adapter {}
+14
View File
@@ -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!"); }
}
+4
View File
@@ -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!"); } }
+8
View File
@@ -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!"); }
}
+4
View File
@@ -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(); }
+4
View File
@@ -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; }
+4
View File
@@ -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); }
+4
View File
@@ -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 {}
+4
View File
@@ -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(); }
+4
View File
@@ -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(); }
@@ -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();
}
@@ -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!"); }
}
@@ -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!"); }
}
@@ -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 {}
@@ -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(); }
@@ -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;
}
@@ -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!"); }
}
@@ -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!"); } }
@@ -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!"); } }
@@ -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);
}
@@ -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!"); }
}
@@ -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!"); }
}
@@ -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!"); }
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more