Compare commits
10
Commits
v1.0
...
bundled-tls
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82acdb79f6 | ||
|
|
478fc60527 | ||
|
|
361adef1a0 | ||
|
|
503beac559 | ||
|
|
9a4f6794d9 | ||
|
|
748f6add54 | ||
|
|
5eeaf0f519 | ||
|
|
f7428287eb | ||
|
|
42c3d6da91 | ||
|
|
f44ecf4871 |
No files matched your search
+6
-6
@@ -1,8 +1,8 @@
|
||||
<?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">
|
||||
android:versionCode="7"
|
||||
android:versionName="1.6">
|
||||
|
||||
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<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">
|
||||
<application android:name=".GingerApp" android:label="@string/app_name" android:icon="@drawable/icon">
|
||||
|
||||
<activity android:name=".MainActivity" android:label="@string/app_name"
|
||||
android:configChanges="keyboardHidden|orientation">
|
||||
@@ -22,15 +22,15 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".SettingsActivity" android:label="Settings"
|
||||
<activity android:name=".SettingsActivity" android:label="@string/app_name"
|
||||
android:configChanges="keyboardHidden|orientation"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize" />
|
||||
|
||||
<activity android:name=".EditActivity" android:label="Event"
|
||||
<activity android:name=".EditActivity" android:label="@string/app_name"
|
||||
android:configChanges="keyboardHidden|orientation"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize" />
|
||||
|
||||
<activity android:name=".CalendarsActivity" android:label="Calendars"
|
||||
<activity android:name=".CalendarsActivity" android:label="@string/app_name"
|
||||
android:configChanges="keyboardHidden|orientation" />
|
||||
|
||||
<service android:name=".SyncService" android:exported="false" />
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
# GingerCal — Nextcloud CalDAV for Android 2.3 (Galaxy Mini)
|
||||
# Calendar Lite — 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.
|
||||
(GT-S5570, 240×320, Android 2.3.5) talking to Nextcloud at its normal `https://` address.
|
||||
|
||||
**Since 1.4 it brings its own TLS 1.2** (Bouncy Castle, the same client as Plexamp Lite, with Mozilla's
|
||||
root certificates and keep-alive connections), like the iPod version does with BearSSL. Put the
|
||||
normal `https://` Nextcloud address in Settings, at home or away: no bridge is needed. The bridge
|
||||
sections below are kept for reference; a bridge address (pinned certificate) still works.
|
||||
|
||||
**What it does**
|
||||
|
||||
- Agenda of upcoming events grouped by day, colour-coded per calendar, cached offline (SQLite)
|
||||
- Month view: a 6-week grid with a coloured mark per event (calendar colour), today outlined,
|
||||
the selected day's events listed below. Swipe or tap the arrows to change month; tap the
|
||||
month name for today; long-press a day to create an event on it. Months outside the synced
|
||||
window are fetched from the server when you open them
|
||||
- Agenda view: upcoming events grouped by day (Menu > View switches; the choice is remembered)
|
||||
- Interface in Galician (default) or English: Settings > Lingua / Language. Month and weekday
|
||||
names are built in, since Android 2.3 has no Galician locale data; in Galician weeks start
|
||||
on Monday. The system date/time pickers follow the phone's own language
|
||||
- Offline cache (SQLite) of the last 35 days and the next 60 (Settings: "Days ahead")
|
||||
- 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
|
||||
- Its own TLS 1.2 stack: connects to modern `https://` servers directly
|
||||
- Certificate pinning, so a self-signed server (or the old legacy-TLS bridge) 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
|
||||
## Why HTTPS broke on 2.3, and the old fix (not needed since 1.4)
|
||||
|
||||
Three separate problems, all fatal for a normal Pangolin/Traefik URL:
|
||||
|
||||
@@ -66,6 +80,24 @@ Protocol** on the resource, add the `pp-transport-v1/v2` block to Pangolin's
|
||||
`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 C: Nginx Proxy Manager with port forwarding (no Pangolin)
|
||||
|
||||
If your router already forwards 80/443 to Nginx Proxy Manager, the legacy endpoint can live in
|
||||
NPM itself on its own port. NPM's normal sites on 443 keep their modern TLS settings.
|
||||
|
||||
1. `./bridge/make-cert.sh nxc.anosanube.ovh <npm>/data/legacy-caldav`, and note the fingerprint.
|
||||
2. Copy `bridge/npm/http.conf` to `<npm>/data/nginx/custom/http.conf`; if that file already
|
||||
exists, append the block to it. NPM includes it at the end of its `http` block.
|
||||
3. In NPM's `docker-compose.yml` add `- '8443:8443'` under `ports`, then `docker compose up -d`.
|
||||
4. Forward TCP 8443 on the router to the NPM host.
|
||||
5. Phone: `https://nxc.anosanube.ovh:8443`, then tap Trust if the fingerprint matches.
|
||||
|
||||
It forwards to NPM's own `nxc.anosanube.ovh` proxy host over loopback HTTPS, so Nextcloud's
|
||||
backend address, HSTS and forced HTTPS need no extra handling. Check from a PC with
|
||||
`openssl s_client -connect nxc.anosanube.ovh:8443 -tls1 -cipher 'AES128-SHA:@SECLEVEL=0'`.
|
||||
At home, the phone needs your router to support NAT loopback (it already does if
|
||||
`https://nxc.anosanube.ovh` works from your Wi-Fi).
|
||||
|
||||
### 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
|
||||
@@ -99,48 +131,70 @@ 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
|
||||
2. Copy `CalendarLite.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,
|
||||
3. Open Calendar Lite, 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.
|
||||
Menu key: View (Month/Agenda), New event, Sync, Today, Calendars, Settings. Tap an event for
|
||||
details, Edit and Delete.
|
||||
|
||||
If installation fails, check the file arrived whole (about 1 MB; some browsers save a truncated or
|
||||
renamed `.apk.zip`) and that there are a few MB free on the internal storage. On a PC with
|
||||
`adb`, `adb install CalendarLite.apk` prints the exact reason (`INSTALL_FAILED_...`).
|
||||
|
||||
## 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.
|
||||
Needs JDK 11+, Python 3, `d8` and `apksigner.jar` (Android SDK build-tools),
|
||||
`apktool.jar` (2.9+, which bundles Google's `aapt` and smali), and the Bouncy Castle sources renamed
|
||||
to `com.minamp.bc` that Plexamp Lite's `tools/setup_toolchain.sh` prepares (`BC_SRC`, default
|
||||
`~/android-toolchain/bcsrc`). No Gradle.
|
||||
|
||||
```sh
|
||||
./build.sh # uses ./stubs to compile
|
||||
ANDROID_JAR=$SDK/platforms/android-10/android.jar ./build.sh # or the real platform jar
|
||||
D8=d8 APKTOOL=~/apktool.jar APKSIGNER=$SDK/build-tools/34.0.0/lib/apksigner.jar ./build.sh
|
||||
# add ANDROID_JAR=$SDK/platforms/android-10/android.jar to compile against the real platform
|
||||
```
|
||||
|
||||
`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.
|
||||
Pipeline: javac → d8 → baksmali/smali (clean dex 035) → `aapt` v1 for manifest and resources →
|
||||
`apksigner` v1 signature → zipalign. The script ends by running `apksigner verify` for API 8–10
|
||||
and checking the dex header, and fails rather than produce an APK that 2.3 would reject.
|
||||
|
||||
Don't sign with `jarsigner`: modern versions add PKCS#7 signed attributes, which Android only
|
||||
accepts from 4.4, so the phone refuses to install it. That was the bug in the first build.
|
||||
`gingercal.keystore` (password `android`) is the signing key; keep using it so updates install
|
||||
over the existing app.
|
||||
|
||||
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
|
||||
javac -d out src/org/gingercal/*.java $(ls test/org/gingercal/*.java | grep -v NetTest)
|
||||
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)
|
||||
java -cp out org.gingercal.RedirectTest # redirect handling (no server needed)
|
||||
# the bundled TLS transport (needs the android.jar and Bouncy Castle sources to compile):
|
||||
# java -cp out org.gingercal.NetTest assets/roots http://127.0.0.1:5232 xose secret
|
||||
java -cp out org.gingercal.I18nTest # Galician dates, titles and messages
|
||||
```
|
||||
|
||||
`tools/check_api.py` verifies every framework call in the dex against published Android API
|
||||
`tools/check_i18n.py` (run by the build) fails if any interface text lacks a Galician
|
||||
translation or the placeholders differ. `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
|
||||
- `android/` activities, sync service, alarms, SQLite cache, `NetHttp` transport
|
||||
- `android/org/gingercal/net/` the bundled TLS 1.2 HTTP client from Plexamp Lite; `assets/roots/` Mozilla roots
|
||||
- `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
|
||||
- `bridge/` legacy-TLS endpoint: standalone nginx (config, compose file), `npm/http.conf` for
|
||||
Nginx Proxy Manager, certificate script
|
||||
- `res/` app name, launcher icon (ldpi to xhdpi; master in `art/`) and `stat_notify`, the white status-bar icon for reminders
|
||||
- `tools/` dex disassembler helper, zipalign, dex/API checkers, d8 workaround
|
||||
|
||||
## Security notes
|
||||
|
||||
With the bundled TLS 1.2 the connection to Nextcloud is checked like a browser would: host name,
|
||||
dates, and a chain up to a Mozilla root. The notes below are about the old bridge.
|
||||
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ public class AlarmReceiver extends BroadcastReceiver {
|
||||
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 = pm.newWakeLock(1 /* PARTIAL_WAKE_LOCK */, "Calendar Lite sync");
|
||||
lock.setReferenceCounted(false);
|
||||
}
|
||||
lock.acquire(5 * 60 * 1000L);
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import org.gingercal.Model.Ev;
|
||||
public final class App {
|
||||
private App() {}
|
||||
|
||||
/** Days of past events kept locally, so this and last month's grid work offline. */
|
||||
public static final int BACK_DAYS = 35;
|
||||
|
||||
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};
|
||||
|
||||
@@ -64,7 +67,7 @@ public final class App {
|
||||
}
|
||||
|
||||
public static Dav dav(String url, String user, String pass, String pin) throws Exception {
|
||||
return new Dav(new ApacheHttp(pin, 30000), url, user, pass);
|
||||
return new Dav(new NetHttp(pin, 30000), url, user, pass);
|
||||
}
|
||||
|
||||
public static boolean online(Context c) {
|
||||
@@ -121,14 +124,14 @@ public final class App {
|
||||
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.";
|
||||
return L.f("Can't resolve %s. No connection, or a typo in the server address.", t.getMessage());
|
||||
}
|
||||
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;
|
||||
if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong address or port, or the server is down?", t.getMessage());
|
||||
if (t instanceof SocketTimeoutException) return L.t("The server took too long to answer.");
|
||||
if (t instanceof SSLException || t instanceof com.minamp.bc.tls.TlsException) return L.f("TLS error: %s", t.getMessage());
|
||||
if (t instanceof IOException) return L.f("Network error: %s", t);
|
||||
if (t instanceof OutOfMemoryError) return L.t("Out of memory. Lower 'Days ahead' in Settings.");
|
||||
return L.f("Error: %s", t);
|
||||
}
|
||||
|
||||
public static void toast(Context c, String s) {
|
||||
@@ -141,22 +144,19 @@ public final class App {
|
||||
|
||||
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.";
|
||||
? L.t("WARNING: the server certificate changed since you trusted it. That happens when a self-signed certificate is regenerated - or when someone is intercepting the connection.\n\n")
|
||||
: L.t("The server certificate isn't signed by a known authority (normal for a self-signed server or an old TLS 1.0 bridge).\n\n"))
|
||||
+ L.f("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.",
|
||||
u.subject, u.issuer, u.expires, u.fingerprint);
|
||||
new AlertDialog.Builder(a)
|
||||
.setTitle(u.pinMismatch ? "Certificate changed!" : "Trust this certificate?")
|
||||
.setTitle(u.pinMismatch ? L.t("Certificate changed!") : L.t("Trust this certificate?"))
|
||||
.setMessage(msg)
|
||||
.setPositiveButton("Trust", new DialogInterface.OnClickListener() {
|
||||
.setPositiveButton(L.t("Trust"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
cb.trusted(u.fingerprint);
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setNegativeButton(L.t("Cancel"), null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@@ -164,12 +164,12 @@ public final class App {
|
||||
|
||||
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));
|
||||
return L.df(p).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));
|
||||
return L.df(thisYear ? "EEE d MMM" : "EEE d MMM yyyy").format(new Date(t));
|
||||
}
|
||||
|
||||
static int year(long t) {
|
||||
@@ -180,16 +180,16 @@ public final class App {
|
||||
|
||||
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);
|
||||
if (dayStart == today) return L.f("Today \u00b7 %s", day(dayStart));
|
||||
if (dayStart == ICal.addDays(today, 1)) return L.f("Tomorrow \u00b7 %s", 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);
|
||||
if (lastDay <= e.start) return L.t("All day");
|
||||
return L.f("All day, until %s", day(lastDay));
|
||||
}
|
||||
String s = time(c, e.start);
|
||||
if (e.end <= e.start) return s;
|
||||
@@ -200,11 +200,11 @@ public final class App {
|
||||
}
|
||||
|
||||
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";
|
||||
if (m < 0) return L.t("No reminder");
|
||||
if (m == 0) return L.t("At start time");
|
||||
if (m < 60) return L.f("%d min before", m);
|
||||
if (m < 1440) return L.f("%d h before", m / 60);
|
||||
return L.f("%d day before", m / 1440);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- views
|
||||
|
||||
@@ -24,13 +24,13 @@ public class CalendarsActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
setTitle("Calendars");
|
||||
setTitle(L.t("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));
|
||||
f.addView(App.text(this, L.t("No calendars yet. Set up the account in Settings and sync."), 14, 0xFFFFFFFF));
|
||||
}
|
||||
for (Cal c : cals) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
@@ -41,7 +41,7 @@ public class CalendarsActivity extends Activity {
|
||||
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.setText(c.name + (c.writable ? "" : " " + L.t("(read-only)")));
|
||||
cb.setChecked(c.enabled);
|
||||
row.addView(cb, new LinearLayout.LayoutParams(-1, -2));
|
||||
boxes.add(cb);
|
||||
@@ -62,9 +62,9 @@ public class CalendarsActivity extends Activity {
|
||||
|
||||
void label() {
|
||||
String def = App.get(this, "defcal");
|
||||
String name = "first writable";
|
||||
String name = L.t("first writable");
|
||||
for (Cal c : cals) if (c.href.equals(def)) name = c.name;
|
||||
defBtn.setText("New events go to: " + name);
|
||||
defBtn.setText(L.f("New events go to: %s", name));
|
||||
}
|
||||
|
||||
void pickDefault() {
|
||||
@@ -72,7 +72,7 @@ public class CalendarsActivity extends Activity {
|
||||
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")
|
||||
new AlertDialog.Builder(this).setTitle(L.t("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();
|
||||
|
||||
@@ -10,7 +10,9 @@ import android.app.AlertDialog;
|
||||
import android.app.DatePickerDialog;
|
||||
import android.app.ProgressDialog;
|
||||
import android.app.TimePickerDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
@@ -41,39 +43,51 @@ public class EditActivity extends Activity {
|
||||
@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");
|
||||
// Events come by value: month view can show events fetched live that aren't in the DB.
|
||||
String href = getIntent().getStringExtra("href");
|
||||
if (href != null) {
|
||||
editing = new Ev();
|
||||
editing.href = href;
|
||||
editing.cal = str("cal");
|
||||
editing.etag = str("etag");
|
||||
editing.title = str("title");
|
||||
editing.location = str("location");
|
||||
editing.descr = str("descr");
|
||||
editing.start = getIntent().getLongExtra("start", 0);
|
||||
editing.end = getIntent().getLongExtra("end", 0);
|
||||
editing.allDay = getIntent().getBooleanExtra("allDay", false);
|
||||
}
|
||||
setTitle(editing == null ? L.t("New event") : L.t("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);
|
||||
title = field(f, L.t("Title"), false);
|
||||
allDay = new CheckBox(this);
|
||||
allDay.setText("All day");
|
||||
allDay.setText(L.t("All day"));
|
||||
allDay.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
f.addView(allDay);
|
||||
f.addView(label("Starts"));
|
||||
f.addView(label(L.t("Starts")));
|
||||
startDate = new Button(this);
|
||||
startTime = new Button(this);
|
||||
f.addView(pair(startDate, startTime));
|
||||
f.addView(label("Ends"));
|
||||
f.addView(label(L.t("Ends")));
|
||||
endDate = new Button(this);
|
||||
endTime = new Button(this);
|
||||
f.addView(pair(endDate, endTime));
|
||||
location = field(f, "Location", false);
|
||||
notes = field(f, "Notes", true);
|
||||
location = field(f, L.t("Location"), false);
|
||||
notes = field(f, L.t("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");
|
||||
save.setText(L.t("Save"));
|
||||
f.addView(save, new LinearLayout.LayoutParams(-1, -2));
|
||||
ScrollView sv = new ScrollView(this);
|
||||
sv.addView(f);
|
||||
@@ -117,7 +131,15 @@ public class EditActivity extends Activity {
|
||||
|
||||
if (editing == null) {
|
||||
long now = System.currentTimeMillis();
|
||||
start.setTimeInMillis(now - now % ICal.HOUR + ICal.HOUR);
|
||||
long day = getIntent().getLongExtra("day", -1);
|
||||
if (day >= 0 && day != ICal.startOfDay(now)) {
|
||||
start.setTimeInMillis(day); // a day picked in the month view: 09:00 there
|
||||
start.set(Calendar.HOUR_OF_DAY, 9);
|
||||
} else {
|
||||
start.setTimeInMillis(now); // next full hour
|
||||
start.add(Calendar.HOUR_OF_DAY, 1);
|
||||
}
|
||||
start.set(Calendar.MINUTE, 0);
|
||||
start.set(Calendar.SECOND, 0);
|
||||
start.set(Calendar.MILLISECOND, 0);
|
||||
end.setTimeInMillis(start.getTimeInMillis() + ICal.HOUR);
|
||||
@@ -134,6 +156,25 @@ public class EditActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
String str(String k) {
|
||||
String v = getIntent().getStringExtra(k);
|
||||
return v == null ? "" : v;
|
||||
}
|
||||
|
||||
static Intent editIntent(Context c, Ev e) {
|
||||
Intent i = new Intent(c, EditActivity.class);
|
||||
i.putExtra("href", e.href);
|
||||
i.putExtra("cal", e.cal);
|
||||
i.putExtra("etag", e.etag);
|
||||
i.putExtra("title", e.title);
|
||||
i.putExtra("location", e.location);
|
||||
i.putExtra("descr", e.descr);
|
||||
i.putExtra("start", e.start);
|
||||
i.putExtra("end", e.end);
|
||||
i.putExtra("allDay", e.allDay);
|
||||
return i;
|
||||
}
|
||||
|
||||
EditText field(LinearLayout f, String hint, boolean multi) {
|
||||
EditText e = new EditText(this);
|
||||
e.setHint(hint);
|
||||
@@ -181,10 +222,10 @@ public class EditActivity extends Activity {
|
||||
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));
|
||||
alarmBtn.setText(L.f("Alarm: %s", App.alarmLabel(alarm)));
|
||||
String name = "?";
|
||||
for (Cal c : Db.get(this).calendarList()) if (c.href.equals(calHref)) name = c.name;
|
||||
calBtn.setText("Calendar: " + name);
|
||||
calBtn.setText(L.f("Calendar: %s", name));
|
||||
}
|
||||
|
||||
void pickDate(final Calendar c, final boolean isStart) {
|
||||
@@ -214,7 +255,7 @@ public class EditActivity extends Activity {
|
||||
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)")
|
||||
new AlertDialog.Builder(this).setTitle(L.t("Alarm (saved in the event)"))
|
||||
.setItems(labels, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
alarm = App.ALARMS[w];
|
||||
@@ -226,7 +267,7 @@ public class EditActivity extends Activity {
|
||||
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")
|
||||
new AlertDialog.Builder(this).setTitle(L.t("Calendar"))
|
||||
.setItems(names, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
calHref = cals.get(w).href;
|
||||
@@ -250,15 +291,15 @@ public class EditActivity extends Activity {
|
||||
e = end.getTimeInMillis() - end.getTimeInMillis() % 60000L;
|
||||
}
|
||||
if (e < s || (ad && e <= s)) {
|
||||
App.toast(this, "The end is before the start.");
|
||||
App.toast(this, L.t("The end is before the start."));
|
||||
return;
|
||||
}
|
||||
if (calHref == null) {
|
||||
App.toast(this, "No writable calendar. Sync first.");
|
||||
App.toast(this, L.t("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();
|
||||
new SaveTask(t.length() > 0 ? t : L.t("(no title)"), txt(location), notes.getText().toString().trim(), s, e, ad).execute();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- network
|
||||
@@ -268,7 +309,7 @@ public class EditActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
pd = ProgressDialog.show(EditActivity.this, null, "Loading…");
|
||||
pd = ProgressDialog.show(EditActivity.this, null, L.t("Loading\u2026"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -291,19 +332,19 @@ public class EditActivity extends Activity {
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
if (r instanceof Throwable) {
|
||||
fail("Can't load event", App.describe((Throwable) r));
|
||||
fail(L.t("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.");
|
||||
fail(L.t("Repeating event"), L.t("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.");
|
||||
fail(L.t("Can't edit"), L.t("The server returned no event data."));
|
||||
return;
|
||||
}
|
||||
ICal.VEvent v = vs.get(0);
|
||||
@@ -316,7 +357,7 @@ public class EditActivity extends Activity {
|
||||
|
||||
void fail(String t, String msg) {
|
||||
new AlertDialog.Builder(this).setTitle(t).setMessage(msg)
|
||||
.setPositiveButton("OK", new DialogInterface.OnClickListener() {
|
||||
.setPositiveButton(L.t("OK"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
finish();
|
||||
}
|
||||
@@ -340,7 +381,7 @@ public class EditActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
pd = ProgressDialog.show(EditActivity.this, null, "Saving…");
|
||||
pd = ProgressDialog.show(EditActivity.this, null, L.t("Saving\u2026"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -370,13 +411,13 @@ public class EditActivity extends Activity {
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
if (x != null) {
|
||||
new AlertDialog.Builder(EditActivity.this).setTitle("Not saved")
|
||||
.setMessage(App.describe(x)).setPositiveButton("OK", null).show();
|
||||
new AlertDialog.Builder(EditActivity.this).setTitle(L.t("Not saved"))
|
||||
.setMessage(App.describe(x)).setPositiveButton(L.t("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");
|
||||
App.toast(EditActivity.this, L.t("Saved"));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.gingercal;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
/** Runs before any screen, service or alarm: interface language (Galician by default), bundled TLS. */
|
||||
public class GingerApp extends Application {
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
L.gl = !"en".equals(getSharedPreferences("gingercal", 0).getString("lang", "gl"));
|
||||
org.gingercal.net.CertVerifier.setRootStore(new org.gingercal.net.AssetRoots(this));
|
||||
org.gingercal.net.Http.USER_AGENT = "CalendarLite/1.6 (Android 2.3)";
|
||||
tlsTexts();
|
||||
}
|
||||
|
||||
/** The bundled TLS stack's messages, in the current language. */
|
||||
static void tlsTexts() {
|
||||
org.gingercal.net.CertVerifier.msgMismatch = L.t("The server certificate is for a different address (%1$s).");
|
||||
org.gingercal.net.CertVerifier.msgDates = L.t("The server certificate has expired, or the phone's date is wrong.");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
package org.gingercal;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
@@ -11,6 +16,7 @@ import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
@@ -26,15 +32,23 @@ 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;
|
||||
/** Main screen: a month grid with the selected day's events, or the upcoming-events agenda. */
|
||||
public class MainActivity extends Activity implements MonthView.Listener {
|
||||
static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4, M_VIEW = 5, M_TODAY = 6;
|
||||
|
||||
TextView status;
|
||||
ListView list;
|
||||
final List<Object> rows = new ArrayList<Object>();
|
||||
final Agenda adapter = new Agenda();
|
||||
boolean askedSetup;
|
||||
LinearLayout root, body;
|
||||
TextView status, monthTitle;
|
||||
ListView agendaList, dayList;
|
||||
MonthView monthView;
|
||||
final List<Object> agendaRows = new ArrayList<Object>(), dayRows = new ArrayList<Object>();
|
||||
final Rows agendaAdapter = new Rows(agendaRows), dayAdapter = new Rows(dayRows);
|
||||
boolean askedSetup, monthMode, uiGl;
|
||||
long selectedDay;
|
||||
List<Ev> monthEvents = new ArrayList<Ev>();
|
||||
/** Months outside the synced window, fetched from the server on demand (key: grid start). */
|
||||
final Map<Long, List<Ev>> fetched = new HashMap<Long, List<Ev>>();
|
||||
long fetching = -1;
|
||||
String fetchError;
|
||||
|
||||
final BroadcastReceiver rx = new BroadcastReceiver() {
|
||||
@Override
|
||||
@@ -44,7 +58,7 @@ public class MainActivity extends Activity {
|
||||
showStatus();
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
reload(true);
|
||||
String fp = i.getStringExtra("fp");
|
||||
if (fp != null) {
|
||||
DavException.UntrustedCert u = new DavException.UntrustedCert(fp, i.getStringExtra("subject"),
|
||||
@@ -62,29 +76,115 @@ public class MainActivity extends Activity {
|
||||
@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);
|
||||
requestWindowFeature(1 /* Window.FEATURE_NO_TITLE: every pixel counts at 240x320 */);
|
||||
uiGl = L.gl;
|
||||
monthMode = !"agenda".equals(App.get(this, "view"));
|
||||
selectedDay = ICal.startOfDay(System.currentTimeMillis());
|
||||
|
||||
root = App.vbox(this);
|
||||
status = App.text(this, "", 11, 0xFFAAAAAA);
|
||||
int p = App.dp(this, 4);
|
||||
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() {
|
||||
|
||||
agendaList = list(agendaAdapter, agendaRows);
|
||||
dayList = list(dayAdapter, dayRows);
|
||||
monthView = new MonthView(this);
|
||||
monthView.listener = this;
|
||||
monthView.setMonth(selectedDay);
|
||||
monthView.setSelected(selectedDay);
|
||||
monthTitle = App.text(this, "", 15, 0xFFFFFFFF);
|
||||
monthTitle.setGravity(17 /* Gravity.CENTER */);
|
||||
monthTitle.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
goToday();
|
||||
}
|
||||
});
|
||||
buildBody();
|
||||
setContentView(root);
|
||||
App.scheduleSync(this);
|
||||
}
|
||||
|
||||
ListView list(Rows adapter, final List<Object> rows) {
|
||||
ListView l = new ListView(this);
|
||||
l.setAdapter(adapter);
|
||||
l.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);
|
||||
return l;
|
||||
}
|
||||
|
||||
static void detach(View v) {
|
||||
// Views can only have one parent; LinearLayout is the only container we use.
|
||||
Object parent = v.getParent();
|
||||
if (parent instanceof LinearLayout) ((LinearLayout) parent).removeView(v);
|
||||
}
|
||||
|
||||
/** (Re)builds the area under the status line for the current mode and orientation. */
|
||||
void buildBody() {
|
||||
if (body != null) root.removeView(body);
|
||||
detach(agendaList);
|
||||
detach(dayList);
|
||||
detach(monthView);
|
||||
detach(monthTitle);
|
||||
body = App.vbox(this);
|
||||
if (!monthMode) {
|
||||
body.addView(agendaList, new LinearLayout.LayoutParams(-1, -1));
|
||||
} else {
|
||||
LinearLayout nav = new LinearLayout(this);
|
||||
nav.setOrientation(LinearLayout.HORIZONTAL);
|
||||
nav.setBackgroundColor(0xFF1C2329);
|
||||
nav.addView(navArrow("\u00ab", -1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34)));
|
||||
nav.addView(monthTitle, new LinearLayout.LayoutParams(0, App.dp(this, 34), 1f));
|
||||
nav.addView(navArrow("\u00bb", 1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34)));
|
||||
body.addView(nav, new LinearLayout.LayoutParams(-1, -2));
|
||||
boolean landscape = getResources().getDisplayMetrics().widthPixels
|
||||
> getResources().getDisplayMetrics().heightPixels;
|
||||
if (landscape) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.addView(monthView, new LinearLayout.LayoutParams(0, -1, 1.25f));
|
||||
row.addView(dayList, new LinearLayout.LayoutParams(0, -1, 1f));
|
||||
body.addView(row, new LinearLayout.LayoutParams(-1, -1));
|
||||
} else {
|
||||
body.addView(monthView, new LinearLayout.LayoutParams(-1, -2));
|
||||
body.addView(dayList, new LinearLayout.LayoutParams(-1, -1));
|
||||
}
|
||||
}
|
||||
root.addView(body, new LinearLayout.LayoutParams(-1, -1));
|
||||
}
|
||||
|
||||
TextView navArrow(String label, final int delta) {
|
||||
TextView b = App.text(this, label, 20, 0xFF3D8EF0); // « »: Latin-1, always in the 2.3 font
|
||||
b.setGravity(17 /* Gravity.CENTER */);
|
||||
int sel = App.sysDrawable(this, "list_selector_background");
|
||||
if (sel != 0) b.setBackgroundResource(sel); // press highlight
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
monthSwiped(delta);
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration c) {
|
||||
super.onConfigurationChanged(c);
|
||||
buildBody();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (uiGl != L.gl) { // language switched in Settings: rebuild every label
|
||||
finish();
|
||||
startActivity(new Intent(this, MainActivity.class));
|
||||
return;
|
||||
}
|
||||
registerReceiver(rx, new IntentFilter(SyncService.STATE));
|
||||
if (!App.configured(this)) {
|
||||
showStatus();
|
||||
@@ -94,7 +194,9 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
long now = ICal.startOfDay(System.currentTimeMillis());
|
||||
if (now != monthView.today) monthView.setMonth(monthView.days[15]); // date rolled over
|
||||
reload(true);
|
||||
long last = App.prefs(this).getLong("lastsync", 0);
|
||||
if (!SyncService.running && System.currentTimeMillis() - last > 15 * ICal.MIN) {
|
||||
App.requestSync(this, true, false);
|
||||
@@ -109,74 +211,264 @@ public class MainActivity extends Activity {
|
||||
|
||||
void showStatus() {
|
||||
if (!App.configured(this)) {
|
||||
status.setText("Not set up yet: press Menu > Settings.");
|
||||
status.setText(L.t("Not set up yet: press Menu > Settings."));
|
||||
return;
|
||||
}
|
||||
if (SyncService.running) {
|
||||
status.setText("Syncing…");
|
||||
status.setText(L.t("Syncing\u2026"));
|
||||
status.setTextColor(0xFFAAAAAA);
|
||||
return;
|
||||
}
|
||||
if (monthMode && fetching >= 0) {
|
||||
status.setText(L.f("Loading %s from the server\u2026", monthTitle.getText()));
|
||||
status.setTextColor(0xFFAAAAAA);
|
||||
return;
|
||||
}
|
||||
if (monthMode && fetchError != null) {
|
||||
status.setText(L.f("Couldn't load this month: %s", fetchError));
|
||||
status.setTextColor(0xFFFF8A80);
|
||||
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);
|
||||
String when = last == 0 ? L.t("never") : App.day(last) + " " + App.time(this, last);
|
||||
if (err) {
|
||||
status.setText("Sync failed: " + s + "\nLast good sync: " + when);
|
||||
status.setText(L.f("Sync failed: %s\nLast good sync: %s", s, when));
|
||||
status.setTextColor(0xFFFF8A80);
|
||||
} else {
|
||||
status.setText("Synced " + when + (s.length() > 0 ? " · " + s : ""));
|
||||
status.setText(L.f("Synced %s", when) + (s.length() > 0 ? " \u00b7 " + s : ""));
|
||||
status.setTextColor(0xFFAAAAAA);
|
||||
}
|
||||
}
|
||||
|
||||
void reload() {
|
||||
rows.clear();
|
||||
void reload(boolean allowFetch) {
|
||||
if (monthMode) loadMonth(allowFetch);
|
||||
else loadAgenda();
|
||||
showStatus();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- agenda
|
||||
|
||||
void loadAgenda() {
|
||||
agendaRows.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));
|
||||
agendaRows.add(Long.valueOf(d));
|
||||
lastDay = d;
|
||||
}
|
||||
rows.add(e);
|
||||
agendaRows.add(e);
|
||||
}
|
||||
if (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync.");
|
||||
adapter.notifyDataSetChanged();
|
||||
if (agendaRows.isEmpty() && App.configured(this)) {
|
||||
agendaRows.add(L.t("No upcoming events. Menu > New event, or Menu > Sync."));
|
||||
}
|
||||
agendaAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- month
|
||||
|
||||
void loadMonth(boolean allowFetch) {
|
||||
long gs = monthView.gridStart(), ge = monthView.gridEnd();
|
||||
monthTitle.setText(L.monthTitle(monthView.days[15]));
|
||||
long wf = App.prefs(this).getLong("win_from", 0), wt = App.prefs(this).getLong("win_to", 0);
|
||||
if (wf > 0 && gs >= wf && ge <= wt) {
|
||||
fetchError = null;
|
||||
showMonthEvents(Db.get(this).agenda(gs, ge));
|
||||
} else {
|
||||
List<Ev> cached = fetched.get(Long.valueOf(gs));
|
||||
showMonthEvents(cached != null ? cached : Db.get(this).agenda(gs, ge));
|
||||
if (allowFetch && fetching != gs && App.configured(this)) new FetchTask(gs, ge).execute();
|
||||
}
|
||||
}
|
||||
|
||||
void showMonthEvents(List<Ev> evs) {
|
||||
monthEvents = evs;
|
||||
Map<Long, List<Integer>> marks = new HashMap<Long, List<Integer>>();
|
||||
long gs = monthView.gridStart(), ge = monthView.gridEnd();
|
||||
for (Ev e : evs) {
|
||||
long d = Math.max(ICal.startOfDay(e.start), gs);
|
||||
for (int guard = 0; d < ge && guard < 42; guard++) {
|
||||
if (!(occupies(e, d))) break;
|
||||
List<Integer> l = marks.get(Long.valueOf(d));
|
||||
if (l == null) {
|
||||
l = new ArrayList<Integer>();
|
||||
marks.put(Long.valueOf(d), l);
|
||||
}
|
||||
l.add(Integer.valueOf(e.color));
|
||||
d = ICal.addDays(d, 1);
|
||||
}
|
||||
}
|
||||
monthView.setMarks(marks);
|
||||
showDay();
|
||||
}
|
||||
|
||||
static boolean occupies(Ev e, long d) {
|
||||
return ICal.onDay(e.start, e.end, d);
|
||||
}
|
||||
|
||||
void showDay() {
|
||||
dayRows.clear();
|
||||
int n = 0;
|
||||
for (Ev e : monthEvents) {
|
||||
if (occupies(e, selectedDay)) {
|
||||
dayRows.add(e);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n == 0) dayRows.add(L.f("Nothing on %s. Long-press a day to add an event.", App.day(selectedDay)));
|
||||
dayAdapter.notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void daySelected(long day) {
|
||||
selectedDay = day;
|
||||
monthView.setSelected(day);
|
||||
if (!monthView.inMonth(day)) {
|
||||
monthView.setMonth(day);
|
||||
reload(true);
|
||||
} else {
|
||||
showDay();
|
||||
}
|
||||
}
|
||||
|
||||
public void dayLongPressed(long day) {
|
||||
daySelected(day);
|
||||
newEvent(day);
|
||||
}
|
||||
|
||||
public void monthSwiped(int delta) {
|
||||
java.util.Calendar c = java.util.Calendar.getInstance();
|
||||
c.setTimeInMillis(monthView.days[15]);
|
||||
c.set(java.util.Calendar.DAY_OF_MONTH, 1);
|
||||
c.add(java.util.Calendar.MONTH, delta);
|
||||
long first = c.getTimeInMillis();
|
||||
monthView.setMonth(first);
|
||||
long today = ICal.startOfDay(System.currentTimeMillis());
|
||||
selectedDay = monthView.inMonth(today) ? today : ICal.startOfDay(first);
|
||||
monthView.setSelected(selectedDay);
|
||||
fetchError = null;
|
||||
reload(true);
|
||||
}
|
||||
|
||||
void goToday() {
|
||||
selectedDay = ICal.startOfDay(System.currentTimeMillis());
|
||||
monthView.setMonth(selectedDay);
|
||||
monthView.setSelected(selectedDay);
|
||||
reload(true);
|
||||
}
|
||||
|
||||
/** Loads a month outside the synced window straight from the server. */
|
||||
class FetchTask extends AsyncTask<Void, Void, Object> {
|
||||
final long gs, ge;
|
||||
|
||||
FetchTask(long gs, long ge) {
|
||||
this.gs = gs;
|
||||
this.ge = ge;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
fetching = gs;
|
||||
fetchError = null;
|
||||
showStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object doInBackground(Void... v) {
|
||||
Dav d = null;
|
||||
try {
|
||||
d = App.dav(MainActivity.this);
|
||||
List<Ev> out = new ArrayList<Ev>();
|
||||
for (Cal c : Db.get(MainActivity.this).calendarList()) {
|
||||
if (!c.enabled) continue;
|
||||
for (Ev e : d.events(c, gs, ge)) {
|
||||
e.color = c.color;
|
||||
e.writable = c.writable;
|
||||
out.add(e);
|
||||
}
|
||||
}
|
||||
java.util.Collections.sort(out, new java.util.Comparator<Ev>() {
|
||||
public int compare(Ev a, Ev b) {
|
||||
return a.start < b.start ? -1 : a.start > b.start ? 1 : 0;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
} finally {
|
||||
if (d != null) d.close();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
protected void onPostExecute(Object r) {
|
||||
if (fetching == gs) fetching = -1;
|
||||
if (r instanceof Throwable) {
|
||||
fetchError = App.describe((Throwable) r);
|
||||
} else {
|
||||
fetched.put(Long.valueOf(gs), (List<Ev>) r);
|
||||
if (monthMode && monthView.gridStart() == gs) showMonthEvents((List<Ev>) r);
|
||||
}
|
||||
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");
|
||||
icon(m.add(0, M_VIEW, 0, L.t("View")), "ic_menu_month");
|
||||
icon(m.add(0, M_NEW, 1, L.t("New event")), "ic_menu_add");
|
||||
icon(m.add(0, M_SYNC, 2, L.t("Sync")), "ic_menu_refresh");
|
||||
icon(m.add(0, M_TODAY, 3, L.t("Today")), "ic_menu_today");
|
||||
icon(m.add(0, M_CALS, 4, L.t("Calendars")), "ic_menu_agenda");
|
||||
icon(m.add(0, M_SETTINGS, 5, L.t("Settings")), "ic_menu_preferences");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu m) {
|
||||
MenuItem v = m.findItem(M_VIEW);
|
||||
if (v != null) {
|
||||
v.setTitle(monthMode ? L.t("Agenda") : L.t("Month"));
|
||||
icon(v, monthMode ? "ic_menu_agenda" : "ic_menu_month");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void icon(MenuItem i, String name) {
|
||||
int id = App.sysDrawable(this, name);
|
||||
if (id == 0) id = App.sysDrawable(this, "ic_menu_my_calendar");
|
||||
if (id != 0) i.setIcon(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case M_VIEW:
|
||||
monthMode = !monthMode;
|
||||
App.prefs(this).edit().putString("view", monthMode ? "month" : "agenda").commit();
|
||||
buildBody();
|
||||
reload(true);
|
||||
return true;
|
||||
case M_TODAY:
|
||||
if (monthMode) goToday();
|
||||
else agendaList.setSelection(0);
|
||||
return true;
|
||||
case M_SYNC:
|
||||
if (App.configured(this)) App.requestSync(this, true, false);
|
||||
else startActivity(new Intent(this, SettingsActivity.class));
|
||||
if (App.configured(this)) {
|
||||
fetched.clear();
|
||||
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));
|
||||
}
|
||||
newEvent(monthMode ? selectedDay : -1);
|
||||
return true;
|
||||
case M_CALS:
|
||||
startActivity(new Intent(this, CalendarsActivity.class));
|
||||
@@ -188,6 +480,17 @@ public class MainActivity extends Activity {
|
||||
return false;
|
||||
}
|
||||
|
||||
void newEvent(long day) {
|
||||
if (writableCalendars().isEmpty()) {
|
||||
App.toast(this, L.t("Sync first: no writable calendar yet."));
|
||||
return;
|
||||
}
|
||||
Intent i = new Intent(this, EditActivity.class);
|
||||
if (day >= 0) i.putExtra("day", day);
|
||||
fetched.clear();
|
||||
startActivity(i);
|
||||
}
|
||||
|
||||
List<Cal> writableCalendars() {
|
||||
List<Cal> out = new ArrayList<Cal>();
|
||||
for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) out.add(c);
|
||||
@@ -202,23 +505,22 @@ public class MainActivity extends Activity {
|
||||
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.");
|
||||
if (cal != null) m.append("\n\n").append(L.f("Calendar: %s", cal.name));
|
||||
if (e.recurring) m.append("\n\n").append(L.t("Repeating 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)")
|
||||
.setTitle(e.title.length() > 0 ? e.title : L.t("(no title)"))
|
||||
.setMessage(m.toString())
|
||||
.setNegativeButton("Close", null);
|
||||
.setNegativeButton(L.t("Close"), null);
|
||||
if (e.writable && !e.recurring) {
|
||||
b.setPositiveButton("Edit", new DialogInterface.OnClickListener() {
|
||||
b.setPositiveButton(L.t("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);
|
||||
fetched.clear();
|
||||
startActivity(EditActivity.editIntent(MainActivity.this, e));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (e.writable) {
|
||||
b.setNeutralButton("Delete", new DialogInterface.OnClickListener() {
|
||||
b.setNeutralButton(L.t("Delete"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
confirmDelete(e);
|
||||
}
|
||||
@@ -229,14 +531,14 @@ public class MainActivity extends Activity {
|
||||
|
||||
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() {
|
||||
.setTitle(L.t("Delete event?"))
|
||||
.setMessage(e.recurring ? L.f("This deletes EVERY occurrence of \"%s\".", e.title) : L.f("\"%s\" will be deleted on the server.", e.title))
|
||||
.setPositiveButton(L.t("Delete"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
new DeleteTask(e).execute();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setNegativeButton(L.t("Cancel"), null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@@ -250,7 +552,7 @@ public class MainActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
pd = ProgressDialog.show(MainActivity.this, null, "Deleting…");
|
||||
pd = ProgressDialog.show(MainActivity.this, null, L.t("Deleting\u2026"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -274,16 +576,21 @@ public class MainActivity extends Activity {
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
if (t != null) {
|
||||
new AlertDialog.Builder(MainActivity.this).setTitle("Delete failed")
|
||||
.setMessage(App.describe(t)).setPositiveButton("OK", null).show();
|
||||
new AlertDialog.Builder(MainActivity.this).setTitle(L.t("Delete failed"))
|
||||
.setMessage(App.describe(t)).setPositiveButton(L.t("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);
|
||||
// Hide it right away; the sync that follows brings the cache in line.
|
||||
List<Ev> keep = new ArrayList<Ev>();
|
||||
for (Ev x : monthEvents) if (!x.href.equals(e.href)) keep.add(x);
|
||||
fetched.clear();
|
||||
for (int i = agendaRows.size() - 1; i >= 0; i--) {
|
||||
Object o = agendaRows.get(i);
|
||||
if (o instanceof Ev && ((Ev) o).href.equals(e.href)) agendaRows.remove(i);
|
||||
}
|
||||
adapter.notifyDataSetChanged();
|
||||
agendaAdapter.notifyDataSetChanged();
|
||||
if (monthMode) showMonthEvents(keep);
|
||||
App.requestSync(MainActivity.this, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +601,14 @@ public class MainActivity extends Activity {
|
||||
TextView time, title, where;
|
||||
}
|
||||
|
||||
class Agenda extends BaseAdapter {
|
||||
/** Day headers (Long), events (Ev) and hint lines (String). */
|
||||
class Rows extends BaseAdapter {
|
||||
final List<Object> rows;
|
||||
|
||||
Rows(List<Object> rows) {
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
return rows.size();
|
||||
}
|
||||
@@ -332,10 +646,12 @@ public class MainActivity extends Activity {
|
||||
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);
|
||||
int pad = App.dp(c, 5);
|
||||
h.setPadding(pad * 2, pad, pad, pad);
|
||||
h.setBackgroundColor(0xFF2E3B44);
|
||||
h.setText(o instanceof Long ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o));
|
||||
boolean header = o instanceof Long;
|
||||
h.setBackgroundColor(header ? 0xFF2E3B44 : 0x00000000);
|
||||
h.setTextColor(header ? 0xFFFFFFFF : 0xFF90A4AE);
|
||||
h.setText(header ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o));
|
||||
return h;
|
||||
}
|
||||
Ev e = (Ev) o;
|
||||
@@ -348,7 +664,7 @@ public class MainActivity extends Activity {
|
||||
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);
|
||||
int pad = App.dp(c, 5);
|
||||
col.setPadding(pad * 2, pad, pad, pad);
|
||||
hd.time = App.text(c, "", 12, 0xFFB0BEC5);
|
||||
hd.title = App.text(c, "", 16, 0xFFFFFFFF);
|
||||
@@ -363,8 +679,8 @@ public class MainActivity extends Activity {
|
||||
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.time.setText(App.when(c, e) + (e.recurring ? " " + L.t("(repeats)") : ""));
|
||||
hd.title.setText(e.title.length() > 0 ? e.title : L.t("(no title)"));
|
||||
hd.where.setText(e.location);
|
||||
hd.where.setVisibility(e.location.length() > 0 ? View.VISIBLE : View.GONE);
|
||||
return v;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.gingercal;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
/**
|
||||
* Month grid drawn by hand (Android 2.3 has no calendar widget). Six weeks of days, each with
|
||||
* up to four coloured marks for its events. Tap selects a day, long-press asks to create an
|
||||
* event on it, a sideways swipe changes month.
|
||||
*/
|
||||
public class MonthView extends View {
|
||||
|
||||
public interface Listener {
|
||||
void daySelected(long day);
|
||||
|
||||
void dayLongPressed(long day);
|
||||
|
||||
void monthSwiped(int delta);
|
||||
}
|
||||
|
||||
static final int BG = 0xFF101418, GRID = 0xFF2A3036, WEEKDAY = 0xFF90A4AE, NUM = 0xFFECEFF1,
|
||||
OTHER = 0xFF5C666E, TODAY = 0xFF3D8EF0, SELECTED = 0xFF2E3B44;
|
||||
|
||||
final float density;
|
||||
final Paint fill = new Paint(1 /* ANTI_ALIAS_FLAG */), text = new Paint(1), line = new Paint();
|
||||
final GestureDetector gestures;
|
||||
final String[] weekdays = new String[7];
|
||||
final long[] days = new long[42];
|
||||
Listener listener;
|
||||
int month = -1; // Calendar.MONTH of the displayed month
|
||||
long selected = -1, today;
|
||||
Map<Long, List<Integer>> marks = new HashMap<Long, List<Integer>>();
|
||||
|
||||
public MonthView(Context c) {
|
||||
super(c);
|
||||
density = c.getResources().getDisplayMetrics().density;
|
||||
line.setColor(GRID);
|
||||
String[] shortNames = L.symbols().getShortWeekdays(); // index 1 = Sunday
|
||||
int first = L.firstDayOfWeek();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
String n = shortNames[(first - 1 + i) % 7 + 1];
|
||||
weekdays[i] = n.length() > 3 ? n.substring(0, 3) : n;
|
||||
}
|
||||
gestures = new GestureDetector(c, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
long d = dayAt(e.getX(), e.getY());
|
||||
if (d >= 0 && listener != null) listener.daySelected(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e) {
|
||||
long d = dayAt(e.getX(), e.getY());
|
||||
if (d >= 0 && listener != null) {
|
||||
performHapticFeedback(0 /* HapticFeedbackConstants.LONG_PRESS */);
|
||||
listener.dayLongPressed(d);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onFling(MotionEvent a, MotionEvent b, float vx, float vy) {
|
||||
if (listener != null && Math.abs(vx) > Math.abs(vy) * 1.5f && Math.abs(vx) > 250 * density) {
|
||||
listener.monthSwiped(vx < 0 ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Shows the month containing `anyDay`, weeks starting on the locale's first weekday. */
|
||||
public void setMonth(long anyDay) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTimeInMillis(ICal.startOfDay(anyDay));
|
||||
c.set(Calendar.DAY_OF_MONTH, 1);
|
||||
month = c.get(Calendar.MONTH);
|
||||
int back = (c.get(Calendar.DAY_OF_WEEK) - L.firstDayOfWeek() + 7) % 7;
|
||||
c.add(Calendar.DAY_OF_MONTH, -back);
|
||||
for (int i = 0; i < 42; i++) {
|
||||
days[i] = c.getTimeInMillis();
|
||||
c.add(Calendar.DAY_OF_MONTH, 1);
|
||||
}
|
||||
today = ICal.startOfDay(System.currentTimeMillis());
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public long gridStart() {
|
||||
return days[0];
|
||||
}
|
||||
|
||||
public long gridEnd() {
|
||||
return ICal.addDays(days[41], 1);
|
||||
}
|
||||
|
||||
public boolean inMonth(long day) {
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTimeInMillis(day);
|
||||
return c.get(Calendar.MONTH) == month;
|
||||
}
|
||||
|
||||
public void setSelected(long day) {
|
||||
selected = day;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setMarks(Map<Long, List<Integer>> m) {
|
||||
marks = m;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- layout
|
||||
|
||||
float headerH() {
|
||||
return 16 * density;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int wSpec, int hSpec) {
|
||||
int w = MeasureSpec.getSize(wSpec);
|
||||
int h;
|
||||
if (MeasureSpec.getMode(hSpec) == MeasureSpec.EXACTLY) {
|
||||
h = MeasureSpec.getSize(hSpec);
|
||||
} else {
|
||||
float cell = Math.max(22 * density, Math.min(w / 7f * 0.66f, 44 * density));
|
||||
h = (int) (headerH() + 6 * cell);
|
||||
if (MeasureSpec.getMode(hSpec) == MeasureSpec.AT_MOST) h = Math.min(h, MeasureSpec.getSize(hSpec));
|
||||
}
|
||||
setMeasuredDimension(w, h);
|
||||
}
|
||||
|
||||
long dayAt(float x, float y) {
|
||||
float cw = getWidth() / 7f, ch = (getHeight() - headerH()) / 6f;
|
||||
if (y < headerH() || cw <= 0 || ch <= 0) return -1;
|
||||
int col = Math.min(6, Math.max(0, (int) (x / cw)));
|
||||
int row = Math.min(5, Math.max(0, (int) ((y - headerH()) / ch)));
|
||||
return days[row * 7 + col];
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent e) {
|
||||
return gestures.onTouchEvent(e);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- drawing
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (month < 0) return;
|
||||
float w = getWidth(), cw = w / 7f, top = headerH(), ch = (getHeight() - top) / 6f;
|
||||
fill.setStyle(Paint.Style.FILL);
|
||||
fill.setColor(BG);
|
||||
canvas.drawRect(0, 0, w, getHeight(), fill);
|
||||
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setFakeBoldText(false);
|
||||
text.setTextSize(11 * density);
|
||||
text.setColor(WEEKDAY);
|
||||
for (int i = 0; i < 7; i++) canvas.drawText(weekdays[i], cw * i + cw / 2, top - 4 * density, text);
|
||||
|
||||
float numSize = Math.min(14 * density, ch * 0.45f);
|
||||
float mark = Math.max(2, 3 * density);
|
||||
for (int i = 0; i < 42; i++) {
|
||||
float x0 = cw * (i % 7), y0 = top + ch * (i / 7);
|
||||
long d = days[i];
|
||||
if (d == selected) {
|
||||
fill.setColor(SELECTED);
|
||||
canvas.drawRect(x0, y0, x0 + cw, y0 + ch, fill);
|
||||
}
|
||||
boolean isToday = d == today, here = inMonth(d);
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTimeInMillis(d);
|
||||
String num = String.valueOf(c.get(Calendar.DAY_OF_MONTH));
|
||||
text.setTextSize(numSize);
|
||||
text.setFakeBoldText(isToday);
|
||||
text.setColor(isToday ? TODAY : here ? NUM : OTHER);
|
||||
float ty = y0 + ch * 0.12f - text.ascent();
|
||||
canvas.drawText(num, x0 + cw / 2, ty, text);
|
||||
if (isToday) {
|
||||
fill.setStyle(Paint.Style.STROKE);
|
||||
fill.setStrokeWidth(Math.max(1, 1.5f * density));
|
||||
fill.setColor(TODAY);
|
||||
canvas.drawRect(x0 + 1, y0 + 1, x0 + cw - 1, y0 + ch - 1, fill);
|
||||
fill.setStyle(Paint.Style.FILL);
|
||||
}
|
||||
List<Integer> m = marks.get(Long.valueOf(d));
|
||||
if (m != null && !m.isEmpty()) {
|
||||
int n = Math.min(4, m.size());
|
||||
float gap = 2 * density, mw = Math.min(6 * density, (cw - gap * 5) / 4);
|
||||
float total = n * mw + (n - 1) * gap, mx = x0 + (cw - total) / 2;
|
||||
float my = Math.min(y0 + ch - mark - 3 * density, ty + text.descent() + 2 * density);
|
||||
for (int k = 0; k < n; k++) {
|
||||
fill.setColor(here ? m.get(k).intValue() : (m.get(k).intValue() & 0x00FFFFFF) | 0x77000000);
|
||||
canvas.drawRect(mx, my, mx + mw, my + mark, fill);
|
||||
mx += mw + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int r = 0; r <= 6; r++) canvas.drawLine(0, top + ch * r, w, top + ch * r, line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.gingercal;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.gingercal.net.CertVerifier;
|
||||
|
||||
/**
|
||||
* Android 2.3 transport through the bundled TLS 1.2 client (org.gingercal.net, from Plexamp Lite):
|
||||
* the phone's own TLS 1.0 can't reach Nextcloud, so this talks to the normal https:// address
|
||||
* directly, any method (PROPFIND/REPORT included), with keep-alive. A pinned certificate (a
|
||||
* self-signed server or an old bridge) is still honoured.
|
||||
*/
|
||||
public class NetHttp implements Http {
|
||||
/** AsyncTask and IntentService threads have Android 2.x's small default stack, which the
|
||||
* TLS handshake overflows: requests run here, on big-stack threads, and the caller waits. */
|
||||
private static final ExecutorService NET = org.gingercal.net.Threads.pool("net", 3);
|
||||
|
||||
final String pin;
|
||||
final int timeoutMs;
|
||||
|
||||
public NetHttp(String pin, int timeoutMs) {
|
||||
this.pin = pin == null ? "" : pin;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public Resp exec(final String method, final String url, final String[] headers, final String body,
|
||||
final String contentType) throws IOException {
|
||||
try {
|
||||
return NET.submit(new Callable<Resp>() {
|
||||
public Resp call() throws IOException {
|
||||
return execHere(method, url, headers, body, contentType);
|
||||
}
|
||||
}).get();
|
||||
} catch (ExecutionException e) {
|
||||
Throwable c = e.getCause();
|
||||
if (c instanceof IOException) throw (IOException) c;
|
||||
if (c instanceof RuntimeException) throw (RuntimeException) c;
|
||||
if (c instanceof Error) throw (Error) c;
|
||||
throw new IOException(String.valueOf(c));
|
||||
} catch (InterruptedException e) {
|
||||
throw new IOException(L.t("Interrupted"));
|
||||
}
|
||||
}
|
||||
|
||||
Resp execHere(String method, String url, String[] headers, String body, String contentType)
|
||||
throws IOException {
|
||||
CertVerifier.setPin(pin);
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
for (int i = 0; i + 1 < headers.length; i += 2) h.put(headers[i], headers[i + 1]);
|
||||
byte[] data = null;
|
||||
if (body != null) {
|
||||
data = body.getBytes("UTF-8");
|
||||
if (contentType != null) h.put("Content-Type", contentType);
|
||||
}
|
||||
org.gingercal.net.Http.Response r;
|
||||
try {
|
||||
// one hop at a time: Dav decides which redirects are safe to follow
|
||||
r = org.gingercal.net.Http.requestNoRedirect(method, url, h, data, timeoutMs);
|
||||
} catch (CertVerifier.Untrusted u) {
|
||||
throw new DavException.UntrustedCert(u.fingerprint, u.subject, u.issuer, u.expires, u.changed);
|
||||
}
|
||||
Resp out = new Resp();
|
||||
out.code = r.code;
|
||||
out.etag = r.header("etag");
|
||||
out.location = r.header("location");
|
||||
out.body = r.readString();
|
||||
return out;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
// pooled connections are shared and expire by themselves
|
||||
}
|
||||
}
|
||||
@@ -43,15 +43,16 @@ public final class Reminders {
|
||||
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");
|
||||
// our own white status-bar glyph: the system's colour alarm-clock icon looks out of place up there
|
||||
int icon = c.getResources().getIdentifier("stat_notify", "drawable", c.getPackageName());
|
||||
if (icon == 0) icon = App.sysDrawable(c, "ic_popup_reminder");
|
||||
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.setLatestEventInfo(c, e.title.length() > 0 ? e.title : L.t("(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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
@@ -20,33 +21,33 @@ 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;
|
||||
Button intervalBtn, remindBtn, langBtn;
|
||||
int interval, remind;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
setTitle("GingerCal settings");
|
||||
setTitle(L.t("Calendar Lite 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 */);
|
||||
f.addView(App.text(this, L.t("Nextcloud server: your normal https:// address. This app brings its own modern TLS, so no bridge is needed, at home or away. Use an app password, not your real one."), 12, 0xFFB0BEC5));
|
||||
url = field(f, L.t("Server address"), "https://cloud.example.com", 1 | 0x10 /* URI */);
|
||||
user = field(f, L.t("User name"), "", 1 | 0x80000 /* no suggestions */);
|
||||
pass = field(f, L.t("App password"), "", 1 | 0x80 /* password */);
|
||||
pin = field(f, L.t("Pinned certificate SHA-256 (filled in for you)"), L.t("optional"), 1 | 0x80000);
|
||||
ahead = field(f, L.t("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));
|
||||
langBtn = new Button(this);
|
||||
f.addView(langBtn, new LinearLayout.LayoutParams(-1, -2));
|
||||
Button test = new Button(this);
|
||||
test.setText("Save and test connection");
|
||||
test.setText(L.t("Save and test connection"));
|
||||
f.addView(test, new LinearLayout.LayoutParams(-1, -2));
|
||||
Button resync = new Button(this);
|
||||
resync.setText("Re-download everything");
|
||||
resync.setText(L.t("Re-download everything"));
|
||||
f.addView(resync, new LinearLayout.LayoutParams(-1, -2));
|
||||
ScrollView sv = new ScrollView(this);
|
||||
sv.addView(f);
|
||||
@@ -65,7 +66,7 @@ public class SettingsActivity extends Activity {
|
||||
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")
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Background sync"))
|
||||
.setItems(l, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
interval = App.INTERVALS[w];
|
||||
@@ -78,8 +79,8 @@ public class SettingsActivity extends Activity {
|
||||
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")
|
||||
for (int i = 0; i < l.length; i++) l[i] = opts[i] < 0 ? L.t("Off") : App.alarmLabel(opts[i]);
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Phone notification"))
|
||||
.setItems(l, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
remind = opts[w];
|
||||
@@ -88,6 +89,23 @@ public class SettingsActivity extends Activity {
|
||||
}).show();
|
||||
}
|
||||
});
|
||||
langBtn.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle("Lingua / Language")
|
||||
.setItems(new String[] {"Galego", "English"}, new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
boolean toGl = w == 0;
|
||||
if (toGl == L.gl) return;
|
||||
save();
|
||||
App.prefs(SettingsActivity.this).edit().putString("lang", toGl ? "gl" : "en").commit();
|
||||
L.gl = toGl;
|
||||
GingerApp.tlsTexts();
|
||||
finish(); // reopen so every label is redrawn in the new language
|
||||
startActivity(new Intent(SettingsActivity.this, SettingsActivity.class));
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
});
|
||||
test.setOnClickListener(new View.OnClickListener() {
|
||||
public void onClick(View v) {
|
||||
confirmAndTest();
|
||||
@@ -98,7 +116,7 @@ public class SettingsActivity extends Activity {
|
||||
save();
|
||||
Db.get(SettingsActivity.this).forgetSyncState();
|
||||
App.requestSync(SettingsActivity.this, true, true);
|
||||
App.toast(SettingsActivity.this, "Re-downloading in the background");
|
||||
App.toast(SettingsActivity.this, L.t("Re-downloading in the background"));
|
||||
finish();
|
||||
}
|
||||
});
|
||||
@@ -117,12 +135,13 @@ public class SettingsActivity extends Activity {
|
||||
}
|
||||
|
||||
static String intervalLabel(int h) {
|
||||
return h == 0 ? "Manual only" : h == 1 ? "Every hour" : "Every " + h + " hours";
|
||||
return h == 0 ? L.t("Manual only") : h == 1 ? L.t("Every hour") : L.f("Every %d hours", h);
|
||||
}
|
||||
|
||||
void labels() {
|
||||
intervalBtn.setText("Background sync: " + intervalLabel(interval));
|
||||
remindBtn.setText("Notify: " + (remind < 0 ? "off" : App.alarmLabel(remind)));
|
||||
intervalBtn.setText(L.f("Background sync: %s", intervalLabel(interval)));
|
||||
remindBtn.setText(L.f("Notification: %s", remind < 0 ? L.t("off") : App.alarmLabel(remind)));
|
||||
langBtn.setText(L.f("Language: %s", L.gl ? "Galego" : "English"));
|
||||
}
|
||||
|
||||
static String txt(TextView t) {
|
||||
@@ -155,7 +174,8 @@ public class SettingsActivity extends Activity {
|
||||
.commit();
|
||||
if (!oldAccount.equals(u + "|" + txt(user))) {
|
||||
Db.get(this).clearAll();
|
||||
App.prefs(this).edit().putLong("lastsync", 0).putString("status", "").commit();
|
||||
App.prefs(this).edit().putLong("lastsync", 0).putLong("win_from", 0).putLong("win_to", 0)
|
||||
.putString("status", "").commit();
|
||||
}
|
||||
App.scheduleSync(this);
|
||||
Reminders.schedule(this);
|
||||
@@ -164,20 +184,19 @@ public class SettingsActivity extends Activity {
|
||||
void confirmAndTest() {
|
||||
String u = txt(url);
|
||||
if (u.length() == 0 || txt(user).length() == 0) {
|
||||
App.toast(this, "Server address and user name are required.");
|
||||
App.toast(this, L.t("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() {
|
||||
new AlertDialog.Builder(this).setTitle(L.t("Unencrypted connection"))
|
||||
.setMessage(L.t("http:// sends your password in clear text over the internet. Use the https:// address instead, or only use http:// to a LAN address."))
|
||||
.setPositiveButton(L.t("Use anyway"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
save();
|
||||
new TestTask().execute();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancel", null).show();
|
||||
.setNegativeButton(L.t("Cancel"), null).show();
|
||||
return;
|
||||
}
|
||||
save();
|
||||
@@ -189,7 +208,7 @@ public class SettingsActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
pd = ProgressDialog.show(SettingsActivity.this, null, "Connecting…");
|
||||
pd = ProgressDialog.show(SettingsActivity.this, null, L.t("Connecting\u2026"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -223,16 +242,16 @@ public class SettingsActivity extends Activity {
|
||||
return;
|
||||
}
|
||||
if (r instanceof Throwable) {
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle("Connection failed")
|
||||
.setMessage(App.describe((Throwable) r)).setPositiveButton("OK", null).show();
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Connection failed"))
|
||||
.setMessage(App.describe((Throwable) r)).setPositiveButton(L.t("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")
|
||||
StringBuilder s = new StringBuilder(L.f("Connected. Calendars found: %d\n", cals.size()));
|
||||
for (Cal c : cals) s.append("\n\u2022 ").append(c.name).append(c.writable ? "" : " " + L.t("(read-only)"));
|
||||
new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("It works"))
|
||||
.setMessage(s.toString())
|
||||
.setPositiveButton("Sync now", new DialogInterface.OnClickListener() {
|
||||
.setPositiveButton(L.t("Sync now"), new DialogInterface.OnClickListener() {
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
App.requestSync(SettingsActivity.this, true, false);
|
||||
finish();
|
||||
|
||||
@@ -26,8 +26,9 @@ public class SyncService extends IntentService {
|
||||
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();
|
||||
Syncer.Result r = Syncer.sync(dav, Db.get(this), now, App.BACK_DAYS, App.ahead(this), force);
|
||||
p.edit().putLong("lastsync", now).putString("status", r.summary()).putBoolean("error", false)
|
||||
.putLong("win_from", r.from).putLong("win_to", r.to).commit();
|
||||
send("done", r.summary(), null);
|
||||
} catch (DavException.UntrustedCert u) {
|
||||
p.edit().putString("status", u.getMessage()).putBoolean("error", true).commit();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.gingercal.net;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
|
||||
public final class AssetRoots implements CertVerifier.RootStore {
|
||||
private final Context ctx;
|
||||
private Set<String> names;
|
||||
|
||||
public AssetRoots(Context c) { ctx = c.getApplicationContext(); }
|
||||
|
||||
public synchronized List<byte[]> find(byte[] subjectDer) {
|
||||
try {
|
||||
if (names == null) {
|
||||
names = new HashSet<String>();
|
||||
String[] l = ctx.getAssets().list("roots");
|
||||
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
|
||||
}
|
||||
String h = CertVerifier.sha1Hex(subjectDer);
|
||||
List<byte[]> out = null;
|
||||
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
|
||||
if (out == null) out = new ArrayList<byte[]>();
|
||||
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
|
||||
}
|
||||
return out;
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] readAll(InputStream in) throws IOException {
|
||||
try {
|
||||
ByteArrayOutputStream b = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) b.write(buf, 0, n);
|
||||
return b.toByteArray();
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package org.gingercal.net;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.minamp.bc.asn1.ASN1Encoding;
|
||||
import com.minamp.bc.asn1.ASN1ObjectIdentifier;
|
||||
import com.minamp.bc.asn1.ASN1OctetString;
|
||||
import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import com.minamp.bc.asn1.x500.RDN;
|
||||
import com.minamp.bc.asn1.x500.X500Name;
|
||||
import com.minamp.bc.asn1.x500.style.BCStyle;
|
||||
import com.minamp.bc.asn1.x500.style.IETFUtils;
|
||||
import com.minamp.bc.asn1.x509.BasicConstraints;
|
||||
import com.minamp.bc.asn1.x509.Certificate;
|
||||
import com.minamp.bc.asn1.x509.Extension;
|
||||
import com.minamp.bc.asn1.x509.Extensions;
|
||||
import com.minamp.bc.asn1.x509.GeneralName;
|
||||
import com.minamp.bc.asn1.x509.GeneralNames;
|
||||
import com.minamp.bc.asn1.x9.X9ObjectIdentifiers;
|
||||
import com.minamp.bc.crypto.Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA1Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA256Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA384Digest;
|
||||
import com.minamp.bc.crypto.digests.SHA512Digest;
|
||||
import com.minamp.bc.crypto.params.AsymmetricKeyParameter;
|
||||
import com.minamp.bc.crypto.signers.DSADigestSigner;
|
||||
import com.minamp.bc.crypto.signers.ECDSASigner;
|
||||
import com.minamp.bc.crypto.signers.RSADigestSigner;
|
||||
import com.minamp.bc.crypto.util.PublicKeyFactory;
|
||||
|
||||
/**
|
||||
* Minimal Web PKI path validation for the bundled TLS stack:
|
||||
* hostname match, validity window, signature chain up to a bundled Mozilla root.
|
||||
* Optionally one pinned leaf certificate (SHA-256) is trusted as-is, for self-signed servers.
|
||||
* Shared with Plexamp Lite (tools/tlskit in the iOS ports' history); keep the copies in sync.
|
||||
*/
|
||||
public final class CertVerifier {
|
||||
|
||||
/** Texts shown to the user; apps replace them with their own language. Formats take the host. */
|
||||
public static String msgMismatch = "The server certificate is for a different address (%1$s).";
|
||||
public static String msgDates = "The server certificate has expired, or the phone's date is wrong.";
|
||||
|
||||
/** Thrown when the certificate is neither CA-trusted nor the pinned one: the app can offer to trust it. */
|
||||
public static final class Untrusted extends IOException {
|
||||
public final String fingerprint, subject, issuer, expires;
|
||||
public final boolean changed; // a pin was set and this is a different certificate
|
||||
public final List<byte[]> chain; // as the server sent it, leaf first
|
||||
|
||||
Untrusted(String fp, String subject, String issuer, String expires, boolean changed, List<byte[]> chain) {
|
||||
super("untrusted certificate " + fp);
|
||||
this.chain = chain;
|
||||
this.fingerprint = fp;
|
||||
this.subject = subject;
|
||||
this.issuer = issuer;
|
||||
this.expires = expires;
|
||||
this.changed = changed;
|
||||
}
|
||||
}
|
||||
|
||||
private static volatile String pin = "";
|
||||
|
||||
/** SHA-256 of the one leaf certificate to accept without a CA (hex, separators ignored); "" for none. */
|
||||
public static void setPin(String p) {
|
||||
String n = norm(p);
|
||||
if (!n.equals(pin)) {
|
||||
pin = n;
|
||||
Http.forgetSessions();
|
||||
}
|
||||
}
|
||||
|
||||
public static String norm(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US);
|
||||
}
|
||||
|
||||
/** "AB:CD:..." SHA-256 of a DER certificate. */
|
||||
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 (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static Untrusted untrusted(List<byte[]> chainDer, boolean changed) {
|
||||
byte[] leafDer = chainDer.get(0);
|
||||
String subject = "?", issuer = "?", expires = "?";
|
||||
try {
|
||||
Certificate c = Certificate.getInstance(leafDer);
|
||||
subject = String.valueOf(c.getSubject());
|
||||
issuer = String.valueOf(c.getIssuer());
|
||||
expires = new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US).format(c.getEndDate().getDate());
|
||||
} catch (Exception ignored) {}
|
||||
return new Untrusted(sha256(leafDer), subject, issuer, expires, changed, chainDer);
|
||||
}
|
||||
|
||||
/** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */
|
||||
public interface RootStore {
|
||||
List<byte[]> find(byte[] subjectDer);
|
||||
}
|
||||
|
||||
private static RootStore roots;
|
||||
/** Chains already proven to lead to a trusted root (keyed by a hash of the chain), so a
|
||||
* reconnect only re-checks the host name and dates, not every signature again. */
|
||||
private static final java.util.Set<String> verifiedChains = new java.util.HashSet<String>();
|
||||
|
||||
/** Changing what's trusted invalidates everything trusted so far: cached chains and TLS sessions. */
|
||||
public static void setRootStore(RootStore store) {
|
||||
roots = store;
|
||||
synchronized (verifiedChains) { verifiedChains.clear(); }
|
||||
Http.forgetSessions();
|
||||
}
|
||||
|
||||
private CertVerifier() {}
|
||||
|
||||
|
||||
public static void verify(List<byte[]> chainDer, String host) throws IOException {
|
||||
if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate");
|
||||
String p = pin;
|
||||
if (p.length() > 0) {
|
||||
// a pinned server: exactly that certificate, whatever its name or issuer
|
||||
if (norm(sha256(chainDer.get(0))).equals(p)) return;
|
||||
throw untrusted(chainDer, true);
|
||||
}
|
||||
if (roots == null) throw new IOException("No trust store configured");
|
||||
java.io.ByteArrayOutputStream all = new java.io.ByteArrayOutputStream();
|
||||
for (int i = 0; i < chainDer.size(); i++) all.write(chainDer.get(i), 0, chainDer.get(i).length);
|
||||
String chainKey = sha1Hex(all.toByteArray());
|
||||
boolean known;
|
||||
synchronized (verifiedChains) { known = verifiedChains.contains(chainKey); }
|
||||
if (known) {
|
||||
Certificate leaf = Certificate.getInstance(chainDer.get(0));
|
||||
if (!hostMatches(leaf, host)) throw new IOException(String.format(msgMismatch, host));
|
||||
if (new Date().getTime() > 1704067200000L) checkDates(leaf, new Date());
|
||||
return;
|
||||
}
|
||||
verifyFully(chainDer, host);
|
||||
synchronized (verifiedChains) { verifiedChains.add(chainKey); }
|
||||
}
|
||||
|
||||
private static void verifyFully(List<byte[]> chainDer, String host) throws IOException {
|
||||
final List<byte[]> chain0 = chainDer;
|
||||
List<Certificate> chain = new ArrayList<Certificate>();
|
||||
for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i)));
|
||||
|
||||
Certificate leaf = chain.get(0);
|
||||
if (!hostMatches(leaf, host)) {
|
||||
throw new IOException(String.format(msgMismatch, host));
|
||||
}
|
||||
|
||||
Date now = new Date();
|
||||
// An unset clock on an old phone (e.g. 1980 after a battery pull) would make every
|
||||
// certificate look "not yet valid"; only enforce dates when the clock looks sane.
|
||||
boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01
|
||||
|
||||
Certificate current = leaf;
|
||||
for (int depth = 0; depth < 8; depth++) {
|
||||
if (clockSane) checkDates(current, now);
|
||||
|
||||
byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER);
|
||||
|
||||
// 1. Anchored directly by a trusted root?
|
||||
List<byte[]> anchors = roots.find(issuerDer);
|
||||
if (anchors != null) {
|
||||
byte[] currentDer = current.getEncoded(ASN1Encoding.DER);
|
||||
for (int i = 0; i < anchors.size(); i++) {
|
||||
// the very certificate the user chose to trust (e.g. self-signed)
|
||||
if (java.util.Arrays.equals(anchors.get(i), currentDer)) return;
|
||||
Certificate root = Certificate.getInstance(anchors.get(i));
|
||||
// anything that signs must be a CA: a trusted site's own certificate can't vouch for another
|
||||
if (isCa(root) && signatureValid(current, root)) return; // trusted
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Otherwise find the issuing intermediate among what the server sent.
|
||||
Certificate next = null;
|
||||
for (int i = 1; i < chain.size(); i++) {
|
||||
Certificate cand = chain.get(i);
|
||||
if (cand == current) continue;
|
||||
if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue;
|
||||
if (!isCa(cand)) continue;
|
||||
if (signatureValid(current, cand)) { next = cand; break; }
|
||||
}
|
||||
if (next == null) throw untrusted(chain0, false);
|
||||
current = next;
|
||||
}
|
||||
throw new IOException("Certificate chain too long");
|
||||
}
|
||||
|
||||
private static void checkDates(Certificate c, Date now) throws IOException {
|
||||
Date from = c.getStartDate().getDate();
|
||||
Date to = c.getEndDate().getDate();
|
||||
if (now.before(from) || now.after(to)) {
|
||||
throw new IOException(msgDates);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCa(Certificate c) {
|
||||
Extensions ext = c.getTBSCertificate().getExtensions();
|
||||
if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates
|
||||
BasicConstraints bc = BasicConstraints.fromExtensions(ext);
|
||||
return bc != null && bc.isCA();
|
||||
}
|
||||
|
||||
static boolean signatureValid(Certificate subject, Certificate issuer) {
|
||||
try {
|
||||
ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm();
|
||||
byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER);
|
||||
byte[] sig = subject.getSignature().getOctets();
|
||||
AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo());
|
||||
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig);
|
||||
if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig);
|
||||
if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
RSADigestSigner s = new RSADigestSigner(d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) {
|
||||
DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d);
|
||||
s.init(false, key);
|
||||
s.update(tbs, 0, tbs.length);
|
||||
return s.verifySignature(sig);
|
||||
}
|
||||
|
||||
// ---- hostname checking ----
|
||||
|
||||
static boolean hostMatches(Certificate leaf, String host) {
|
||||
host = host.toLowerCase(Locale.US);
|
||||
boolean ip = isIpLiteral(host);
|
||||
boolean sawDns = false;
|
||||
Extensions ext = leaf.getTBSCertificate().getExtensions();
|
||||
if (ext != null) {
|
||||
GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName);
|
||||
if (names != null) {
|
||||
GeneralName[] arr = names.getNames();
|
||||
for (int i = 0; i < arr.length; i++) {
|
||||
GeneralName n = arr[i];
|
||||
if (n.getTagNo() == GeneralName.dNSName) {
|
||||
sawDns = true;
|
||||
// String.valueOf, not getName().toString(): Dalvik < 4.0 can't call Object methods via an interface
|
||||
if (!ip && dnsMatch(String.valueOf(n.getName()), host)) return true;
|
||||
} else if (n.getTagNo() == GeneralName.iPAddress && ip) {
|
||||
byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets();
|
||||
if (ipString(a).equals(host)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sawDns && !ip) {
|
||||
X500Name subject = X500Name.getInstance(leaf.getSubject());
|
||||
RDN[] cns = subject.getRDNs(BCStyle.CN);
|
||||
for (int i = 0; i < cns.length; i++) {
|
||||
String cn = IETFUtils.valueToString(cns[i].getFirst().getValue());
|
||||
if (dnsMatch(cn, host)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean dnsMatch(String pattern, String host) {
|
||||
pattern = pattern.toLowerCase(Locale.US);
|
||||
if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1);
|
||||
if (host.endsWith(".")) host = host.substring(0, host.length() - 1);
|
||||
if (pattern.startsWith("*.")) {
|
||||
String suffix = pattern.substring(1); // ".example.com"
|
||||
if (!host.endsWith(suffix)) return false;
|
||||
String label = host.substring(0, host.length() - suffix.length());
|
||||
return label.length() > 0 && label.indexOf('.') < 0;
|
||||
}
|
||||
return pattern.equals(host);
|
||||
}
|
||||
|
||||
static boolean isIpLiteral(String host) {
|
||||
if (host.indexOf(':') >= 0) return true;
|
||||
for (int i = 0; i < host.length(); i++) {
|
||||
char c = host.charAt(i);
|
||||
if (!(c == '.' || (c >= '0' && c <= '9'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String ipString(byte[] a) {
|
||||
if (a.length == 4) {
|
||||
return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public static String sha1Hex(byte[] data) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
byte[] h = md.digest(data);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < h.length; i++) {
|
||||
String s = Integer.toHexString(h[i] & 255);
|
||||
if (s.length() == 1) sb.append('0');
|
||||
sb.append(s);
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.gingercal.net;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* In-memory diagnostics log (last few hundred lines): every request with its status and timing,
|
||||
* sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem
|
||||
* on a real server can be reported precisely. Tokens are never logged.
|
||||
*/
|
||||
public final class DiagLog {
|
||||
private static final int MAX = 400;
|
||||
private static final List<String> lines = new ArrayList<String>();
|
||||
private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
|
||||
|
||||
private DiagLog() {}
|
||||
|
||||
public static void log(String msg) {
|
||||
String line;
|
||||
synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; }
|
||||
synchronized (lines) {
|
||||
lines.add(line);
|
||||
if (lines.size() > MAX) lines.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
public static String dump() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
synchronized (lines) {
|
||||
for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
synchronized (lines) { lines.clear(); }
|
||||
}
|
||||
|
||||
/** URL without the token value, safe to show and share. */
|
||||
public static String redact(String url) {
|
||||
if (url == null) return "";
|
||||
return url.replaceAll("((?:X-Plex-Token|token|T|Passwd)=)[^&]*", "$1***");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package org.gingercal.net;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Vector;
|
||||
|
||||
import com.minamp.bc.tls.DefaultTlsClient;
|
||||
import com.minamp.bc.tls.NameType;
|
||||
import com.minamp.bc.tls.ServerName;
|
||||
import com.minamp.bc.tls.TlsAuthentication;
|
||||
import com.minamp.bc.tls.TlsClientProtocol;
|
||||
import com.minamp.bc.tls.TlsCredentials;
|
||||
import com.minamp.bc.tls.TlsServerCertificate;
|
||||
import com.minamp.bc.tls.TlsSession;
|
||||
import com.minamp.bc.tls.CertificateRequest;
|
||||
import com.minamp.bc.tls.crypto.TlsCrypto;
|
||||
import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto;
|
||||
|
||||
/**
|
||||
* Small HTTP/1.1 client with its own TLS 1.2 (Bouncy Castle), keep-alive pooling and
|
||||
* session resumption. Android 2.x's built-in SSL only speaks TLS 1.0 and lacks today's roots,
|
||||
* so every request in the app goes through here. From Plexamp Lite; keep the copies in sync.
|
||||
*/
|
||||
public final class Http {
|
||||
|
||||
public static String USER_AGENT = "PlexampLite/1.0";
|
||||
|
||||
/** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */
|
||||
public static String proxyHost;
|
||||
public static int proxyPort;
|
||||
|
||||
private static TlsCrypto crypto;
|
||||
private static final Map<String, TlsSession> sessions = new HashMap<String, TlsSession>();
|
||||
private static final Map<String, List<Conn>> pool = new HashMap<String, List<Conn>>();
|
||||
private static final long IDLE_MS = 25000;
|
||||
|
||||
private Http() {}
|
||||
|
||||
private static synchronized TlsCrypto crypto() {
|
||||
if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom());
|
||||
return crypto;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ URL
|
||||
|
||||
public static final class Url {
|
||||
public final boolean https;
|
||||
public final String host;
|
||||
public final int port;
|
||||
public final String path; // includes query
|
||||
|
||||
public Url(String s) throws IOException {
|
||||
String lower = s.toLowerCase(Locale.US);
|
||||
int start;
|
||||
if (lower.startsWith("https://")) { https = true; start = 8; }
|
||||
else if (lower.startsWith("http://")) { https = false; start = 7; }
|
||||
else throw new IOException("Bad URL: " + s);
|
||||
int slash = s.indexOf('/', start);
|
||||
String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash);
|
||||
path = slash < 0 ? "/" : s.substring(slash);
|
||||
int colon = hostPort.lastIndexOf(':');
|
||||
if (colon > 0 && hostPort.indexOf(']') < colon) {
|
||||
host = hostPort.substring(0, colon);
|
||||
try { port = Integer.parseInt(hostPort.substring(colon + 1)); }
|
||||
catch (NumberFormatException e) { throw new IOException("Bad port in " + s); }
|
||||
} else {
|
||||
host = hostPort;
|
||||
port = https ? 443 : 80;
|
||||
}
|
||||
}
|
||||
|
||||
String key() { return (https ? "s:" : "p:") + host + ":" + port; }
|
||||
|
||||
String hostHeader() {
|
||||
boolean dflt = (https && port == 443) || (!https && port == 80);
|
||||
return dflt ? host : host + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ connection
|
||||
|
||||
static final class Conn {
|
||||
final String key;
|
||||
Socket socket;
|
||||
InputStream in;
|
||||
OutputStream out;
|
||||
TlsClientProtocol tls;
|
||||
long idleSince;
|
||||
|
||||
Conn(String key) { this.key = key; }
|
||||
|
||||
void close() {
|
||||
try { if (tls != null) tls.close(); } catch (Exception ignored) {}
|
||||
try { if (socket != null) socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static Conn take(String key) {
|
||||
synchronized (pool) {
|
||||
List<Conn> list = pool.get(key);
|
||||
if (list == null) return null;
|
||||
long now = System.currentTimeMillis();
|
||||
while (!list.isEmpty()) {
|
||||
Conn c = list.remove(list.size() - 1);
|
||||
if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c;
|
||||
c.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void release(Conn c) {
|
||||
c.idleSince = System.currentTimeMillis();
|
||||
synchronized (pool) {
|
||||
List<Conn> list = pool.get(c.key);
|
||||
if (list == null) { list = new ArrayList<Conn>(); pool.put(c.key, list); }
|
||||
if (list.size() >= 4) { list.remove(0).close(); }
|
||||
list.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget resumable TLS sessions (next connection to each host does a full, verified handshake). */
|
||||
static void forgetSessions() {
|
||||
synchronized (sessions) { sessions.clear(); }
|
||||
evictAll();
|
||||
}
|
||||
|
||||
/** Drop all pooled connections (e.g. after the network changed). */
|
||||
public static void evictAll() {
|
||||
final List<Conn> doomed = new ArrayList<Conn>();
|
||||
synchronized (pool) {
|
||||
for (Iterator<List<Conn>> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next());
|
||||
pool.clear();
|
||||
}
|
||||
if (doomed.isEmpty()) return;
|
||||
// closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread
|
||||
Threads.start(new Runnable() {
|
||||
public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); }
|
||||
}, "http-evict");
|
||||
}
|
||||
|
||||
private static Conn connect(final Url u, int timeoutMs) throws IOException {
|
||||
Conn c = new Conn(u.key());
|
||||
Socket s = new Socket();
|
||||
boolean ok = false;
|
||||
try {
|
||||
if (proxyHost != null) {
|
||||
s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs);
|
||||
s.setSoTimeout(timeoutMs);
|
||||
OutputStream po = s.getOutputStream();
|
||||
String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n";
|
||||
po.write(req.getBytes("ISO-8859-1"));
|
||||
po.flush();
|
||||
InputStream pi = s.getInputStream();
|
||||
String status = readLine(pi);
|
||||
if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status);
|
||||
String line;
|
||||
while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ }
|
||||
} else {
|
||||
s.connect(new InetSocketAddress(u.host, u.port), timeoutMs);
|
||||
s.setSoTimeout(timeoutMs);
|
||||
}
|
||||
s.setTcpNoDelay(true);
|
||||
c.socket = s;
|
||||
if (u.https) {
|
||||
TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream());
|
||||
proto.connect(new Client(crypto(), u.host, u.key()));
|
||||
c.tls = proto;
|
||||
c.in = new BufferedInputStream(proto.getInputStream(), 8192);
|
||||
c.out = new BufferedOutputStream(proto.getOutputStream(), 4096);
|
||||
} else {
|
||||
c.in = new BufferedInputStream(s.getInputStream(), 8192);
|
||||
c.out = new BufferedOutputStream(s.getOutputStream(), 4096);
|
||||
}
|
||||
ok = true;
|
||||
return c;
|
||||
} finally {
|
||||
if (!ok) { try { s.close(); } catch (Exception ignored) {} }
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Client extends DefaultTlsClient {
|
||||
private final String host;
|
||||
private final String sessionKey;
|
||||
|
||||
private final long started = System.currentTimeMillis();
|
||||
|
||||
Client(TlsCrypto crypto, String host, String sessionKey) {
|
||||
super(crypto);
|
||||
this.host = host;
|
||||
this.sessionKey = sessionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Vector getSNIServerNames() {
|
||||
if (CertVerifier.isIpLiteral(host)) return null;
|
||||
Vector v = new Vector();
|
||||
try {
|
||||
v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII")));
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS 1.2 only: this TLS stack can resume 1.2 sessions but not 1.3 ones, and on a 600 MHz
|
||||
* phone a resumed handshake (no key exchange, no signature checks) is the difference
|
||||
* between ~10 s and well under a second for every new connection.
|
||||
*/
|
||||
@Override
|
||||
protected com.minamp.bc.tls.ProtocolVersion[] getSupportedVersions() {
|
||||
return com.minamp.bc.tls.ProtocolVersion.TLSv12.only();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TlsSession getSessionToResume() {
|
||||
synchronized (sessions) { return sessions.get(sessionKey); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyHandshakeComplete() throws IOException {
|
||||
super.notifyHandshakeComplete();
|
||||
boolean resumed = context.getSecurityParametersConnection().isResumedSession();
|
||||
DiagLog.log("TLS " + (resumed ? "resumed" : "full handshake") + " with " + host
|
||||
+ " in " + (System.currentTimeMillis() - started) + "ms");
|
||||
TlsSession s = context.getSession();
|
||||
if (s != null && s.isResumable()) {
|
||||
synchronized (sessions) { sessions.put(sessionKey, s); }
|
||||
}
|
||||
}
|
||||
|
||||
public TlsAuthentication getAuthentication() throws IOException {
|
||||
return new TlsAuthentication() {
|
||||
public void notifyServerCertificate(TlsServerCertificate sc) throws IOException {
|
||||
com.minamp.bc.tls.Certificate cert = sc.getCertificate();
|
||||
List<byte[]> chain = new ArrayList<byte[]>();
|
||||
for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded());
|
||||
CertVerifier.verify(chain, host);
|
||||
}
|
||||
|
||||
public TlsCredentials getClientCredentials(CertificateRequest req) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ request / response
|
||||
|
||||
public static final class Response {
|
||||
public final int code;
|
||||
public final String status;
|
||||
public final Map<String, String> headers; // lower-case names
|
||||
public final InputStream body;
|
||||
public final long contentLength; // -1 if unknown
|
||||
public final boolean chunked;
|
||||
|
||||
Response(int code, String status, Map<String, String> headers, InputStream body, long len, boolean chunked) {
|
||||
this.code = code; this.status = status; this.headers = headers; this.body = body;
|
||||
this.contentLength = len; this.chunked = chunked;
|
||||
}
|
||||
|
||||
public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); }
|
||||
|
||||
public void close() {
|
||||
try { body.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
public byte[] readAll() throws IOException {
|
||||
try {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = body.read(buf)) > 0) bos.write(buf, 0, n);
|
||||
return bos.toByteArray();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
public String readString() throws IOException {
|
||||
return new String(readAll(), "UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
public static Response get(String url, Map<String, String> headers, int timeoutMs) throws IOException {
|
||||
return request("GET", url, headers, null, timeoutMs);
|
||||
}
|
||||
|
||||
public static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
return request(method, url, headers, body, timeoutMs, true);
|
||||
}
|
||||
|
||||
/** A single request; 3xx responses are returned to the caller. */
|
||||
public static Response requestNoRedirect(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
return request(method, url, headers, body, timeoutMs, false);
|
||||
}
|
||||
|
||||
private static Response request(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs, boolean follow) throws IOException {
|
||||
for (int redirects = 0; ; redirects++) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
Response r;
|
||||
try {
|
||||
r = requestOnce(method, url, headers, body, timeoutMs);
|
||||
} catch (IOException e) {
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e));
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e));
|
||||
throw e;
|
||||
}
|
||||
if (!url.contains("/photo/:/transcode") && !url.contains("/cover")) { // artwork would drown out everything else
|
||||
DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)");
|
||||
}
|
||||
if (follow && (r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4
|
||||
&& ("GET".equals(method) || "HEAD".equals(method))) {
|
||||
String loc = r.header("location");
|
||||
r.close();
|
||||
if (loc == null) throw new IOException("Redirect without location");
|
||||
if (loc.startsWith("/")) {
|
||||
Url u = new Url(url);
|
||||
loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc;
|
||||
}
|
||||
url = loc;
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
private static Response requestOnce(String method, String url, Map<String, String> headers, byte[] body, int timeoutMs) throws IOException {
|
||||
Url u = new Url(url);
|
||||
// A pooled connection may have been closed by the server; retry once on a fresh one.
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
Conn c = take(u.key());
|
||||
boolean pooled = c != null;
|
||||
if (c == null) c = connect(u, timeoutMs);
|
||||
try {
|
||||
c.socket.setSoTimeout(timeoutMs);
|
||||
writeRequest(c, u, method, headers, body);
|
||||
return readResponse(c, method);
|
||||
} catch (IOException e) {
|
||||
c.close();
|
||||
if (!pooled) throw e;
|
||||
}
|
||||
}
|
||||
throw new IOException("Request failed");
|
||||
}
|
||||
|
||||
private static void writeRequest(Conn c, Url u, String method, Map<String, String> headers, byte[] body) throws IOException {
|
||||
StringBuilder sb = new StringBuilder(512);
|
||||
sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n");
|
||||
sb.append("Host: ").append(u.hostHeader()).append("\r\n");
|
||||
boolean hasUa = false;
|
||||
if (headers != null) {
|
||||
for (Map.Entry<String, String> e : headers.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true;
|
||||
sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n");
|
||||
}
|
||||
}
|
||||
if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n");
|
||||
sb.append("Connection: keep-alive\r\n");
|
||||
if (body != null || "POST".equals(method) || "PUT".equals(method)) {
|
||||
sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n");
|
||||
}
|
||||
sb.append("\r\n");
|
||||
c.out.write(sb.toString().getBytes("UTF-8"));
|
||||
if (body != null) c.out.write(body);
|
||||
c.out.flush();
|
||||
}
|
||||
|
||||
private static Response readResponse(Conn c, String method) throws IOException {
|
||||
String statusLine = readLine(c.in);
|
||||
if (statusLine == null) throw new IOException("Connection closed");
|
||||
// HTTP/1.1 200 OK
|
||||
int sp = statusLine.indexOf(' ');
|
||||
if (sp < 0) throw new IOException("Bad status line");
|
||||
int sp2 = statusLine.indexOf(' ', sp + 1);
|
||||
int code;
|
||||
try {
|
||||
code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IOException("Bad status line");
|
||||
}
|
||||
boolean http10 = statusLine.startsWith("HTTP/1.0");
|
||||
Map<String, String> h = new LinkedHashMap<String, String>();
|
||||
String line;
|
||||
while ((line = readLine(c.in)) != null && line.length() > 0) {
|
||||
int colon = line.indexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
String name = line.substring(0, colon).trim().toLowerCase(Locale.US);
|
||||
String val = line.substring(colon + 1).trim();
|
||||
String prev = h.get(name);
|
||||
h.put(name, prev == null ? val : prev + ", " + val);
|
||||
}
|
||||
String te = h.get("transfer-encoding");
|
||||
String cl = h.get("content-length");
|
||||
String conn = h.get("connection");
|
||||
boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0);
|
||||
boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0;
|
||||
long len = -1;
|
||||
if (!chunked && cl != null) {
|
||||
try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {}
|
||||
}
|
||||
InputStream body;
|
||||
boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200);
|
||||
if (noBody) {
|
||||
body = new FixedStream(c, 0, keepAlive);
|
||||
len = "HEAD".equals(method) ? len : 0;
|
||||
} else if (chunked) {
|
||||
body = new ChunkedStream(c, keepAlive);
|
||||
} else if (len >= 0) {
|
||||
body = new FixedStream(c, len, keepAlive);
|
||||
} else {
|
||||
body = new EofStream(c);
|
||||
}
|
||||
return new Response(code, statusLine, h, body, len, chunked);
|
||||
}
|
||||
|
||||
static String readLine(InputStream in) throws IOException {
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
int b;
|
||||
while ((b = in.read()) >= 0) {
|
||||
if (b == '\n') {
|
||||
int n = sb.length();
|
||||
if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1);
|
||||
return sb.toString();
|
||||
}
|
||||
sb.append((char) b);
|
||||
if (sb.length() > 16384) throw new IOException("Header line too long");
|
||||
}
|
||||
return sb.length() == 0 ? null : sb.toString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ body streams
|
||||
|
||||
private static final class FixedStream extends InputStream {
|
||||
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
|
||||
|
||||
FixedStream(Conn c, long len, boolean keepAlive) {
|
||||
this.c = c; this.left = len; this.keepAlive = keepAlive;
|
||||
if (len == 0) finish(true);
|
||||
}
|
||||
|
||||
private void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int n = read(one, 0, 1);
|
||||
return n <= 0 ? -1 : one[0] & 255;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (left <= 0) { finish(true); return -1; }
|
||||
if (closed) return -1;
|
||||
int n = c.in.read(b, off, (int) Math.min(len, left));
|
||||
if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); }
|
||||
left -= n;
|
||||
if (left == 0) finish(true);
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public int available() throws IOException {
|
||||
if (closed) return 0;
|
||||
return (int) Math.min(c.in.available(), left);
|
||||
}
|
||||
|
||||
@Override public void close() { finish(left == 0); }
|
||||
}
|
||||
|
||||
private static final class ChunkedStream extends InputStream {
|
||||
private final Conn c; private final boolean keepAlive;
|
||||
private long chunkLeft = 0; private boolean eof; private boolean closed;
|
||||
|
||||
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
|
||||
|
||||
private void finish(boolean complete) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (complete && keepAlive) release(c); else c.close();
|
||||
}
|
||||
|
||||
private boolean nextChunk() throws IOException {
|
||||
if (eof) return false;
|
||||
String line = readLine(c.in);
|
||||
if (line == null) throw new IOException("Unexpected end of chunked stream");
|
||||
if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); }
|
||||
int semi = line.indexOf(';');
|
||||
if (semi >= 0) line = line.substring(0, semi);
|
||||
try { chunkLeft = Long.parseLong(line.trim(), 16); }
|
||||
catch (NumberFormatException e) { throw new IOException("Bad chunk size"); }
|
||||
if (chunkLeft == 0) {
|
||||
String t;
|
||||
while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ }
|
||||
eof = true;
|
||||
finish(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
byte[] one = new byte[1];
|
||||
int n = read(one, 0, 1);
|
||||
return n <= 0 ? -1 : one[0] & 255;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (closed) return -1;
|
||||
if (chunkLeft == 0) {
|
||||
if (!nextChunk()) return -1;
|
||||
}
|
||||
int n = c.in.read(b, off, (int) Math.min(len, chunkLeft));
|
||||
if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); }
|
||||
chunkLeft -= n;
|
||||
if (chunkLeft == 0) {
|
||||
// consume CRLF after chunk data
|
||||
String crlf = readLine(c.in);
|
||||
if (crlf == null) { finish(false); }
|
||||
else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public int available() throws IOException {
|
||||
if (closed) return 0;
|
||||
return (int) Math.min(c.in.available(), chunkLeft);
|
||||
}
|
||||
|
||||
@Override public void close() { finish(eof); }
|
||||
}
|
||||
|
||||
private static final class EofStream extends InputStream {
|
||||
private final Conn c; private boolean closed;
|
||||
EofStream(Conn c) { this.c = c; }
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
if (closed) return -1;
|
||||
int b = c.in.read();
|
||||
if (b < 0) close();
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (closed) return -1;
|
||||
int n = c.in.read(b, off, len);
|
||||
if (n < 0) close();
|
||||
return n;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (!closed) { closed = true; c.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Close one in-flight response's socket from another thread (used to abort streams). */
|
||||
public static void abort(Response r) {
|
||||
if (r == null) return;
|
||||
InputStream b = r.body;
|
||||
Conn c = null;
|
||||
if (b instanceof FixedStream) c = ((FixedStream) b).c;
|
||||
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
|
||||
else if (b instanceof EofStream) c = ((EofStream) b).c;
|
||||
if (c != null) {
|
||||
try { c.socket.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.gingercal.net;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
/**
|
||||
* Background threads with big stacks. Android 2.x gives new threads ~16 KB of stack, and the
|
||||
* bundled TLS handshake + certificate parsing needs far more; overflowing it kills the app.
|
||||
* 256 KB is the most Dalvik allows.
|
||||
*/
|
||||
public final class Threads {
|
||||
public static final long STACK = 256 * 1024;
|
||||
|
||||
private Threads() {}
|
||||
|
||||
public static Thread create(Runnable r, String name) {
|
||||
Thread t = new Thread(null, r, name, STACK);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
|
||||
public static void start(Runnable r, String name) {
|
||||
create(r, name).start();
|
||||
}
|
||||
|
||||
public static ExecutorService pool(final String name, int n) {
|
||||
ThreadFactory f = new ThreadFactory() {
|
||||
private int count;
|
||||
public synchronized Thread newThread(Runnable r) { return create(r, name + "-" + (++count)); }
|
||||
};
|
||||
return n == 1 ? Executors.newSingleThreadExecutor(f) : Executors.newFixedThreadPool(n, f);
|
||||
}
|
||||
|
||||
/** Readable one-liner for any failure, including Errors such as StackOverflowError. */
|
||||
public static String describe(Throwable t) {
|
||||
String m = t.getMessage();
|
||||
String cls = t.getClass().getSimpleName();
|
||||
if (m == null || m.length() == 0) return cls;
|
||||
if (t instanceof java.io.IOException) return m;
|
||||
return cls + ": " + m;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 973 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user