Import GingerCal 1.0 source
This commit is contained in:
commit
01f49852ab
116 files changed
+5079
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
build/
|
||||||
|
out/
|
||||||
|
bridge/certs/
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
*.apk
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Executable
+15
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("<");
|
||||||
|
else if (c == '>') b.append(">");
|
||||||
|
else if (c == '&') b.append("&");
|
||||||
|
else if (c == '"') b.append(""");
|
||||||
|
else b.append(c);
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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); }
|
||||||
@@ -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); }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
|
||||||
|
package android.widget;
|
||||||
|
|
||||||
|
public interface SpinnerAdapter extends Adapter {}
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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!"); } }
|
||||||
@@ -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!"); }
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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 {}
|
||||||
@@ -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(); }
|
||||||
@@ -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
Reference in new issue
Block a user