Import GingerCal 1.0 source

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

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
package org.gingercal;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.PowerManager;
/** Boot, periodic-sync and reminder alarms all land here. */
public class AlarmReceiver extends BroadcastReceiver {
public static final String SYNC = "org.gingercal.SYNC";
public static final String REMIND = "org.gingercal.REMIND";
private static PowerManager.WakeLock lock;
@Override
public void onReceive(Context c, Intent i) {
String a = i.getAction();
if (REMIND.equals(a)) {
Reminders.fire(c);
} else if (SYNC.equals(a)) {
acquire(c);
App.requestSync(c, false, false);
} else {
// BOOT_COMPLETED / package replaced: alarms don't survive either
App.scheduleSync(c);
Reminders.schedule(c);
}
}
static synchronized void acquire(Context c) {
if (lock == null) {
PowerManager pm = (PowerManager) c.getApplicationContext().getSystemService("power");
lock = pm.newWakeLock(1 /* PARTIAL_WAKE_LOCK */, "GingerCal sync");
lock.setReferenceCounted(false);
}
lock.acquire(5 * 60 * 1000L);
}
static synchronized void release() {
if (lock != null && lock.isHeld()) lock.release();
}
}
+177
View File
@@ -0,0 +1,177 @@
package org.gingercal;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.security.GeneralSecurityException;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.conn.scheme.LayeredSocketFactory;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
/**
* Android 2.3 transport. HttpURLConnection refuses PROPFIND/REPORT there, so this uses the
* bundled Apache HttpClient, plus a TLS layer that supports certificate pinning (needed for a
* self-signed legacy-TLS bridge) and SNI where the platform offers it.
*/
public class ApacheHttp implements Http {
final DefaultHttpClient client;
final Tls.PinTrust trust;
public ApacheHttp(String pin, int timeoutMs) throws GeneralSecurityException {
trust = new Tls.PinTrust(pin);
HttpParams p = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(p, timeoutMs);
HttpConnectionParams.setSoTimeout(p, timeoutMs);
HttpConnectionParams.setStaleCheckingEnabled(p, true);
HttpProtocolParams.setUseExpectContinue(p, false);
HttpProtocolParams.setUserAgent(p, "GingerCal/1.0 (Android 2.3)");
p.setBooleanParameter("http.protocol.handle-redirects", false);
SchemeRegistry reg = new SchemeRegistry();
reg.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
reg.register(new Scheme("https", new TlsFactory(trust), 443));
client = new DefaultHttpClient(new ThreadSafeClientConnManager(p, reg), p);
}
static class Req extends HttpEntityEnclosingRequestBase {
final String m;
Req(String m, URI u) {
this.m = m;
setURI(u);
}
@Override
public String getMethod() {
return m;
}
}
public Resp exec(String method, String url, String[] headers, String body, String contentType)
throws IOException {
Req r = new Req(method, URI.create(url));
for (int i = 0; i + 1 < headers.length; i += 2) r.addHeader(headers[i], headers[i + 1]);
if (body != null) {
StringEntity e = new StringEntity(body, "UTF-8");
e.setContentType(contentType);
r.setEntity(e);
}
trust.reset();
HttpResponse res;
try {
res = client.execute(r);
} catch (IOException e) {
throw trust.translate(e);
}
Resp out = new Resp();
out.code = res.getStatusLine().getStatusCode();
Header h = res.getFirstHeader("ETag");
if (h != null) out.etag = h.getValue();
h = res.getFirstHeader("Location");
if (h != null) out.location = h.getValue();
HttpEntity en = res.getEntity();
if (en != null) {
InputStream in = en.getContent();
try {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) bo.write(buf, 0, n);
out.body = new String(bo.toByteArray(), "UTF-8");
} finally {
en.consumeContent();
}
}
return out;
}
public void close() {
client.getConnectionManager().shutdown();
}
// ------------------------------------------------------------------ TLS
static class TlsFactory implements LayeredSocketFactory {
final SSLSocketFactory f;
final Tls.PinTrust trust;
TlsFactory(Tls.PinTrust trust) throws GeneralSecurityException {
this.trust = trust;
SSLContext c = SSLContext.getInstance("TLS");
c.init(null, new TrustManager[] {trust}, null);
f = c.getSocketFactory();
}
public Socket createSocket() throws IOException {
return f.createSocket();
}
public Socket connectSocket(Socket sock, String host, int port, InetAddress local, int localPort,
HttpParams params) throws IOException {
SSLSocket s = (SSLSocket) (sock != null ? sock : createSocket());
if (local != null || localPort > 0) {
s.bind(new InetSocketAddress(local, localPort < 0 ? 0 : localPort));
}
sni(s, host);
s.connect(new InetSocketAddress(host, port), HttpConnectionParams.getConnectionTimeout(params));
s.setSoTimeout(HttpConnectionParams.getSoTimeout(params));
s.startHandshake();
verifyHost(s, host);
return s;
}
public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException {
SSLSocket s = (SSLSocket) f.createSocket(socket, host, port, autoClose);
sni(s, host);
s.startHandshake();
verifyHost(s, host);
return s;
}
public boolean isSecure(Socket sock) {
return true;
}
void verifyHost(SSLSocket s, String host) throws IOException {
if (trust.pinned()) return; // the pin identifies the exact certificate
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(host, s.getSession())) {
s.close();
throw new SSLException("Certificate is not valid for " + host);
}
}
/** Gingerbread's OpenSSLSocketImpl has a hidden setHostname() for SNI. */
static void sni(SSLSocket s, String host) {
if (host.matches("[0-9.]+") || host.indexOf(':') >= 0) return;
try {
Method m = s.getClass().getMethod("setHostname", String.class);
m.invoke(s, host);
} catch (Throwable ignored) {
}
}
}
}
+233
View File
@@ -0,0 +1,233 @@
package org.gingercal;
import java.io.IOException;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.UnknownHostException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import javax.net.ssl.SSLException;
import android.app.Activity;
import android.app.AlarmManager;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import org.gingercal.Model.Ev;
/** Shared helpers: settings, formatting, scheduling, dialogs. */
public final class App {
private App() {}
public static final int[] INTERVALS = {0, 1, 3, 6, 12, 24};
public static final int[] ALARMS = {-1, 0, 5, 10, 15, 30, 60, 120, 1440};
public static SharedPreferences prefs(Context c) {
return c.getSharedPreferences("gingercal", 0);
}
public static String get(Context c, String k) {
return prefs(c).getString(k, "");
}
public static boolean configured(Context c) {
return get(c, "url").length() > 0 && get(c, "user").length() > 0;
}
public static int interval(Context c) {
return prefs(c).getInt("interval", 3);
}
public static int remind(Context c) {
return prefs(c).getInt("remind", 15);
}
public static int ahead(Context c) {
return prefs(c).getInt("ahead", 60);
}
public static Dav dav(Context c) throws Exception {
return dav(get(c, "url"), get(c, "user"), get(c, "pass"), get(c, "pin"));
}
public static Dav dav(String url, String user, String pass, String pin) throws Exception {
return new Dav(new ApacheHttp(pin, 30000), url, user, pass);
}
public static boolean online(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService("connectivity");
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected();
}
public static void requestSync(Context c, boolean manual, boolean force) {
Intent i = new Intent(c, SyncService.class);
i.putExtra("manual", manual);
i.putExtra("force", force);
c.startService(i);
}
/** (Re)arms the periodic background sync. */
public static void scheduleSync(Context c) {
AlarmManager am = (AlarmManager) c.getSystemService("alarm");
Intent i = new Intent(c, AlarmReceiver.class);
i.setAction(AlarmReceiver.SYNC);
PendingIntent pi = PendingIntent.getBroadcast(c, 1, i, 0);
am.cancel(pi);
int h = interval(c);
if (h > 0 && configured(c)) {
long every = h * ICal.HOUR;
am.setInexactRepeating(1 /* RTC: don't wake the phone just to sync */,
System.currentTimeMillis() + every, every, pi);
}
}
public static boolean isLan(String url) {
try {
String h = URI.create(Dav.normalize(url)).getHost();
if (h == null) return false;
h = h.toLowerCase(Locale.US);
if (h.startsWith("10.") || h.startsWith("192.168.") || h.equals("localhost") || h.endsWith(".local")
|| h.endsWith(".lan") || h.endsWith(".home.arpa")) return true;
if (h.startsWith("172.")) {
String[] p = h.split("\\.");
int b = Integer.parseInt(p[1]);
return b >= 16 && b <= 31;
}
if (h.startsWith("100.")) { // CGNAT / Tailscale-style ranges
int b = Integer.parseInt(h.split("\\.")[1]);
return b >= 64 && b <= 127;
}
} catch (RuntimeException ignored) {
}
return false;
}
// ------------------------------------------------------------ messages
public static String describe(Throwable t) {
if (t instanceof DavException || t instanceof DavException.UntrustedCert) return t.getMessage();
if (t instanceof UnknownHostException) {
return "Can't resolve " + t.getMessage() + ". No connection, or a typo in the server address.";
}
if (t instanceof ConnectException) return "Can't connect to the server (" + t.getMessage() + "). Wrong port, or the bridge is down?";
if (t instanceof SocketTimeoutException) return "The server took too long to answer.";
if (t instanceof SSLException) return "TLS error: " + t.getMessage();
if (t instanceof IOException) return "Network error: " + t;
if (t instanceof OutOfMemoryError) return "Out of memory. Lower 'Days ahead' in Settings.";
return "Error: " + t;
}
public static void toast(Context c, String s) {
Toast.makeText(c, s, 1).show();
}
public interface Trusted {
void trusted(String fingerprint);
}
public static void askTrust(Activity a, final DavException.UntrustedCert u, final Trusted cb) {
String msg = (u.pinMismatch
? "WARNING: the server certificate changed since you trusted it. That happens when you "
+ "regenerate the bridge certificate - or when someone is intercepting the connection.\n\n"
: "This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, "
+ "or for Let's Encrypt on Android 2.3).\n\n")
+ "Subject: " + u.subject + "\nIssuer: " + u.issuer + "\nExpires: " + u.expires
+ "\n\nSHA-256:\n" + u.fingerprint
+ "\n\nOnly trust it if this matches the fingerprint printed on your server.";
new AlertDialog.Builder(a)
.setTitle(u.pinMismatch ? "Certificate changed!" : "Trust this certificate?")
.setMessage(msg)
.setPositiveButton("Trust", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
cb.trusted(u.fingerprint);
}
})
.setNegativeButton("Cancel", null)
.show();
}
// ------------------------------------------------------------ formatting
public static String time(Context c, long t) {
String p = android.text.format.DateFormat.is24HourFormat(c) ? "HH:mm" : "h:mma";
return new SimpleDateFormat(p, Locale.getDefault()).format(new Date(t));
}
public static String day(long t) {
boolean thisYear = year(t) == year(System.currentTimeMillis());
return new SimpleDateFormat(thisYear ? "EEE d MMM" : "EEE d MMM yyyy", Locale.getDefault()).format(new Date(t));
}
static int year(long t) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(t);
return c.get(Calendar.YEAR);
}
public static String dayHeader(long dayStart) {
long today = ICal.startOfDay(System.currentTimeMillis());
if (dayStart == today) return "Today · " + day(dayStart);
if (dayStart == ICal.addDays(today, 1)) return "Tomorrow · " + day(dayStart);
return day(dayStart);
}
public static String when(Context c, Ev e) {
if (e.allDay) {
long lastDay = ICal.addDays(e.end, -1);
if (lastDay <= e.start) return "All day";
return "All day, until " + day(lastDay);
}
String s = time(c, e.start);
if (e.end <= e.start) return s;
if (ICal.startOfDay(e.end - 1) != ICal.startOfDay(e.start)) {
return s + " – " + day(e.end) + " " + time(c, e.end);
}
return s + " – " + time(c, e.end);
}
public static String alarmLabel(int m) {
if (m < 0) return "No reminder";
if (m == 0) return "At start time";
if (m < 60) return m + " min before";
if (m < 1440) return (m / 60) + " h before";
return (m / 1440) + " day before";
}
// --------------------------------------------------------------- views
public static int dp(Context c, float v) {
return (int) (v * c.getResources().getDisplayMetrics().density + 0.5f);
}
public static TextView text(Context c, String s, float sp, int color) {
TextView t = new TextView(c);
t.setText(s);
t.setTextSize(sp);
t.setTextColor(color);
return t;
}
public static LinearLayout vbox(Context c) {
LinearLayout l = new LinearLayout(c);
l.setOrientation(LinearLayout.VERTICAL);
return l;
}
public static int sysDrawable(Context c, String name) {
return c.getResources().getIdentifier(name, "drawable", "android");
}
}
@@ -0,0 +1,98 @@
package org.gingercal;
import java.util.ArrayList;
import java.util.List;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import org.gingercal.Model.Cal;
/** Pick which calendars to show and which one new events go to. */
public class CalendarsActivity extends Activity {
final List<Cal> cals = new ArrayList<Cal>();
final List<CheckBox> boxes = new ArrayList<CheckBox>();
Button defBtn;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setTitle("Calendars");
cals.addAll(Db.get(this).calendarList());
LinearLayout f = App.vbox(this);
int p = App.dp(this, 8);
f.setPadding(p, p, p, p);
if (cals.isEmpty()) {
f.addView(App.text(this, "No calendars yet. Set up the account in Settings and sync.", 14, 0xFFFFFFFF));
}
for (Cal c : cals) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
View bar = new View(this);
bar.setBackgroundColor(c.color);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(App.dp(this, 6), -1);
lp.setMargins(0, App.dp(this, 6), App.dp(this, 4), App.dp(this, 6));
row.addView(bar, lp);
CheckBox cb = new CheckBox(this);
cb.setText(c.name + (c.writable ? "" : " (read-only)"));
cb.setChecked(c.enabled);
row.addView(cb, new LinearLayout.LayoutParams(-1, -2));
boxes.add(cb);
f.addView(row, new LinearLayout.LayoutParams(-1, -2));
}
defBtn = new Button(this);
defBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickDefault();
}
});
if (!cals.isEmpty()) f.addView(defBtn, new LinearLayout.LayoutParams(-1, -2));
ScrollView sv = new ScrollView(this);
sv.addView(f);
setContentView(sv);
label();
}
void label() {
String def = App.get(this, "defcal");
String name = "first writable";
for (Cal c : cals) if (c.href.equals(def)) name = c.name;
defBtn.setText("New events go to: " + name);
}
void pickDefault() {
final List<Cal> w = new ArrayList<Cal>();
for (Cal c : cals) if (c.writable) w.add(c);
String[] names = new String[w.size()];
for (int i = 0; i < names.length; i++) names[i] = w.get(i).name;
new AlertDialog.Builder(this).setTitle("Default calendar")
.setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
App.prefs(CalendarsActivity.this).edit().putString("defcal", w.get(which).href).commit();
label();
}
}).show();
}
@Override
protected void onPause() {
super.onPause();
boolean changed = false;
for (int i = 0; i < cals.size(); i++) {
boolean on = boxes.get(i).isChecked();
if (on != cals.get(i).enabled) {
Db.get(this).setEnabled(cals.get(i).href, on);
cals.get(i).enabled = on;
changed = true;
}
}
if (changed) App.requestSync(this, true, false);
}
}
+193
View File
@@ -0,0 +1,193 @@
package org.gingercal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
/** Local cache so the agenda works offline and the phone doesn't refetch on every launch. */
public class Db extends SQLiteOpenHelper implements Model.Store {
private static Db instance;
public static synchronized Db get(Context c) {
if (instance == null) instance = new Db(c.getApplicationContext());
return instance;
}
private Db(Context c) {
super(c, "gingercal.db", null, 1);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE cals (href TEXT PRIMARY KEY, name TEXT, color INTEGER, enabled INTEGER,"
+ " writable INTEGER, ctag TEXT, synced INTEGER)");
db.execSQL("CREATE TABLE events (_id INTEGER PRIMARY KEY AUTOINCREMENT, cal TEXT, href TEXT, etag TEXT,"
+ " uid TEXT, title TEXT, location TEXT, descr TEXT, dtstart INTEGER, dtend INTEGER,"
+ " allday INTEGER, recurring INTEGER)");
db.execSQL("CREATE INDEX ev_start ON events(dtstart)");
db.execSQL("CREATE INDEX ev_cal ON events(cal)");
}
@Override
public void onUpgrade(SQLiteDatabase db, int from, int to) {
db.execSQL("DROP TABLE IF EXISTS events");
db.execSQL("DROP TABLE IF EXISTS cals");
onCreate(db);
}
// ------------------------------------------------------------ calendars
public synchronized Map<String, Cal> calendars() {
Map<String, Cal> m = new LinkedHashMap<String, Cal>();
Cursor c = getReadableDatabase().rawQuery(
"SELECT href, name, color, enabled, writable, ctag, synced FROM cals ORDER BY name", null);
try {
while (c.moveToNext()) {
Cal x = new Cal();
x.href = c.getString(0);
x.name = c.getString(1);
x.color = c.getInt(2);
x.enabled = c.getInt(3) != 0;
x.writable = c.getInt(4) != 0;
x.ctag = c.getString(5);
x.synced = c.getLong(6);
m.put(x.href, x);
}
} finally {
c.close();
}
return m;
}
public List<Cal> calendarList() {
return new ArrayList<Cal>(calendars().values());
}
public synchronized void saveCalendar(Cal c) {
getWritableDatabase().execSQL("INSERT OR REPLACE INTO cals (href, name, color, enabled, writable, ctag, synced)"
+ " VALUES (?,?,?,?,?,?,?)", new Object[] {c.href, c.name, Integer.valueOf(c.color),
Integer.valueOf(c.enabled ? 1 : 0), Integer.valueOf(c.writable ? 1 : 0), c.ctag, Long.valueOf(c.synced)});
}
public synchronized void setEnabled(String href, boolean on) {
// reset ctag/synced so the next sync refetches it
getWritableDatabase().execSQL("UPDATE cals SET enabled=?, ctag='', synced=0 WHERE href=?",
new Object[] {Integer.valueOf(on ? 1 : 0), href});
if (!on) getWritableDatabase().execSQL("DELETE FROM events WHERE cal=?", new Object[] {href});
}
public synchronized void deleteCalendar(String href) {
SQLiteDatabase db = getWritableDatabase();
db.execSQL("DELETE FROM events WHERE cal=?", new Object[] {href});
db.execSQL("DELETE FROM cals WHERE href=?", new Object[] {href});
}
public synchronized void forgetSyncState() {
getWritableDatabase().execSQL("UPDATE cals SET ctag='', synced=0");
}
public synchronized void clearAll() {
SQLiteDatabase db = getWritableDatabase();
db.execSQL("DELETE FROM events");
db.execSQL("DELETE FROM cals");
}
// --------------------------------------------------------------- events
public synchronized void replaceEvents(String cal, List<Ev> evs) {
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
db.execSQL("DELETE FROM events WHERE cal=?", new Object[] {cal});
for (Ev e : evs) {
db.execSQL("INSERT INTO events (cal, href, etag, uid, title, location, descr, dtstart, dtend, allday,"
+ " recurring) VALUES (?,?,?,?,?,?,?,?,?,?,?)", new Object[] {cal, e.href, e.etag, e.uid,
e.title, e.location, e.descr, Long.valueOf(e.start), Long.valueOf(e.end),
Integer.valueOf(e.allDay ? 1 : 0), Integer.valueOf(e.recurring ? 1 : 0)});
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
static final String EV_COLS = "e._id, e.cal, e.href, e.etag, e.uid, e.title, e.location, e.descr, e.dtstart,"
+ " e.dtend, e.allday, e.recurring, c.color, c.writable";
static Ev read(Cursor c) {
Ev e = new Ev();
e.id = c.getLong(0);
e.cal = c.getString(1);
e.href = c.getString(2);
e.etag = c.getString(3);
e.uid = c.getString(4);
e.title = c.getString(5);
e.location = c.getString(6);
e.descr = c.getString(7);
e.start = c.getLong(8);
e.end = c.getLong(9);
e.allDay = c.getInt(10) != 0;
e.recurring = c.getInt(11) != 0;
e.color = c.getInt(12);
e.writable = c.getInt(13) != 0;
return e;
}
List<Ev> query(String where, String[] args) {
List<Ev> out = new ArrayList<Ev>();
Cursor c = getReadableDatabase().rawQuery("SELECT " + EV_COLS + " FROM events e JOIN cals c ON c.href = e.cal"
+ " WHERE c.enabled = 1 AND (" + where + ") ORDER BY e.dtstart, e.allday DESC, e.title", args);
try {
while (c.moveToNext()) out.add(read(c));
} finally {
c.close();
}
return out;
}
/** Everything still running or starting after `from`. */
public synchronized List<Ev> agenda(long from, long to) {
return query("e.dtend > ? AND e.dtstart < ? OR (e.dtstart = e.dtend AND e.dtstart >= ? AND e.dtstart < ?)",
new String[] {String.valueOf(from), String.valueOf(to), String.valueOf(from), String.valueOf(to)});
}
public synchronized Ev event(long id) {
List<Ev> l = query("e._id = ?", new String[] {String.valueOf(id)});
return l.isEmpty() ? null : l.get(0);
}
/** Timed events starting in (after, until]. */
public synchronized List<Ev> startingBetween(long after, long until) {
return query("e.allday = 0 AND e.dtstart > ? AND e.dtstart <= ?",
new String[] {String.valueOf(after), String.valueOf(until)});
}
public synchronized long nextStartAfter(long after) {
Cursor c = getReadableDatabase().rawQuery("SELECT MIN(e.dtstart) FROM events e JOIN cals c ON c.href = e.cal"
+ " WHERE c.enabled = 1 AND e.allday = 0 AND e.dtstart > ?", new String[] {String.valueOf(after)});
try {
if (c.moveToNext() && !c.isNull(0)) return c.getLong(0);
return -1;
} finally {
c.close();
}
}
public synchronized int count() {
Cursor c = getReadableDatabase().rawQuery("SELECT COUNT(*) FROM events", null);
try {
return c.moveToNext() ? c.getInt(0) : 0;
} finally {
c.close();
}
}
}
+383
View File
@@ -0,0 +1,383 @@
package org.gingercal;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.UUID;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.DatePickerDialog;
import android.app.ProgressDialog;
import android.app.TimePickerDialog;
import android.content.DialogInterface;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.DatePicker;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.TimePicker;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
/** Create or edit a single (non-repeating) event. */
public class EditActivity extends Activity {
EditText title, location, notes;
CheckBox allDay;
Button startDate, startTime, endDate, endTime, alarmBtn, calBtn, save;
final Calendar start = Calendar.getInstance(), end = Calendar.getInstance();
int alarm = 15, originalAlarm = -3;
String calHref;
Ev editing;
String ics, etag;
List<Cal> cals = new ArrayList<Cal>();
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
long id = getIntent().getLongExtra("id", -1);
if (id >= 0) editing = Db.get(this).event(id);
setTitle(editing == null ? "New event" : "Edit event");
for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) cals.add(c);
LinearLayout f = App.vbox(this);
int p = App.dp(this, 8);
f.setPadding(p, p, p, p);
title = field(f, "Title", false);
allDay = new CheckBox(this);
allDay.setText("All day");
allDay.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
refresh();
}
});
f.addView(allDay);
f.addView(label("Starts"));
startDate = new Button(this);
startTime = new Button(this);
f.addView(pair(startDate, startTime));
f.addView(label("Ends"));
endDate = new Button(this);
endTime = new Button(this);
f.addView(pair(endDate, endTime));
location = field(f, "Location", false);
notes = field(f, "Notes", true);
alarmBtn = new Button(this);
f.addView(alarmBtn, new LinearLayout.LayoutParams(-1, -2));
calBtn = new Button(this);
f.addView(calBtn, new LinearLayout.LayoutParams(-1, -2));
save = new Button(this);
save.setText("Save");
f.addView(save, new LinearLayout.LayoutParams(-1, -2));
ScrollView sv = new ScrollView(this);
sv.addView(f);
setContentView(sv);
startDate.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickDate(start, true);
}
});
endDate.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickDate(end, false);
}
});
startTime.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickTime(start, true);
}
});
endTime.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickTime(end, false);
}
});
alarmBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickAlarm();
}
});
calBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
pickCalendar();
}
});
save.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
save();
}
});
if (editing == null) {
long now = System.currentTimeMillis();
start.setTimeInMillis(now - now % ICal.HOUR + ICal.HOUR);
start.set(Calendar.SECOND, 0);
start.set(Calendar.MILLISECOND, 0);
end.setTimeInMillis(start.getTimeInMillis() + ICal.HOUR);
String def = App.get(this, "defcal");
calHref = cals.isEmpty() ? null : cals.get(0).href;
for (Cal c : cals) if (c.href.equals(def)) calHref = def;
refresh();
} else {
calHref = editing.cal;
calBtn.setEnabled(false);
fill(editing.title, editing.location, editing.descr, editing.start, editing.end, editing.allDay);
save.setEnabled(false);
new LoadTask().execute();
}
}
EditText field(LinearLayout f, String hint, boolean multi) {
EditText e = new EditText(this);
e.setHint(hint);
if (multi) {
e.setInputType(1 | 0x4000 | 0x20000); // text | cap sentences | multi-line
e.setMinLines(3);
} else {
e.setSingleLine(true);
e.setInputType(1 | 0x4000);
}
f.addView(e, new LinearLayout.LayoutParams(-1, -2));
return e;
}
TextView label(String s) {
TextView t = App.text(this, s, 13, 0xFFB0BEC5);
t.setPadding(App.dp(this, 4), App.dp(this, 6), 0, 0);
return t;
}
LinearLayout pair(Button a, Button b) {
LinearLayout l = new LinearLayout(this);
l.setOrientation(LinearLayout.HORIZONTAL);
l.addView(a, new LinearLayout.LayoutParams(0, -2, 3f));
l.addView(b, new LinearLayout.LayoutParams(0, -2, 2f));
return l;
}
void fill(String t, String loc, String desc, long s, long e, boolean ad) {
title.setText(t);
location.setText(loc);
notes.setText(desc);
allDay.setChecked(ad);
start.setTimeInMillis(s);
// all-day DTEND is exclusive; show the last day instead
end.setTimeInMillis(ad ? Math.max(s, ICal.addDays(e, -1)) : e);
refresh();
}
void refresh() {
boolean ad = allDay.isChecked();
startDate.setText(App.day(start.getTimeInMillis()));
endDate.setText(App.day(end.getTimeInMillis()));
startTime.setText(App.time(this, start.getTimeInMillis()));
endTime.setText(App.time(this, end.getTimeInMillis()));
startTime.setVisibility(ad ? View.GONE : View.VISIBLE);
endTime.setVisibility(ad ? View.GONE : View.VISIBLE);
alarmBtn.setText("Alarm: " + App.alarmLabel(alarm));
String name = "?";
for (Cal c : Db.get(this).calendarList()) if (c.href.equals(calHref)) name = c.name;
calBtn.setText("Calendar: " + name);
}
void pickDate(final Calendar c, final boolean isStart) {
new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
public void onDateSet(DatePicker v, int y, int m, int d) {
long dur = end.getTimeInMillis() - start.getTimeInMillis();
c.set(y, m, d);
if (isStart) end.setTimeInMillis(start.getTimeInMillis() + Math.max(0, dur));
refresh();
}
}, c.get(Calendar.YEAR), c.get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH)).show();
}
void pickTime(final Calendar c, final boolean isStart) {
new TimePickerDialog(this, new TimePickerDialog.OnTimeSetListener() {
public void onTimeSet(TimePicker v, int h, int m) {
long dur = end.getTimeInMillis() - start.getTimeInMillis();
c.set(Calendar.HOUR_OF_DAY, h);
c.set(Calendar.MINUTE, m);
if (isStart) end.setTimeInMillis(start.getTimeInMillis() + Math.max(0, dur));
refresh();
}
}, c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE),
android.text.format.DateFormat.is24HourFormat(this)).show();
}
void pickAlarm() {
String[] labels = new String[App.ALARMS.length];
for (int i = 0; i < labels.length; i++) labels[i] = App.alarmLabel(App.ALARMS[i]);
new AlertDialog.Builder(this).setTitle("Alarm (saved in the event)")
.setItems(labels, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
alarm = App.ALARMS[w];
refresh();
}
}).show();
}
void pickCalendar() {
String[] names = new String[cals.size()];
for (int i = 0; i < names.length; i++) names[i] = cals.get(i).name;
new AlertDialog.Builder(this).setTitle("Calendar")
.setItems(names, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
calHref = cals.get(w).href;
refresh();
}
}).show();
}
static String txt(TextView t) {
return t.getText().toString().trim();
}
void save() {
boolean ad = allDay.isChecked();
long s, e;
if (ad) {
s = ICal.startOfDay(start.getTimeInMillis());
e = ICal.addDays(ICal.startOfDay(end.getTimeInMillis()), 1);
} else {
s = start.getTimeInMillis() - start.getTimeInMillis() % 60000L;
e = end.getTimeInMillis() - end.getTimeInMillis() % 60000L;
}
if (e < s || (ad && e <= s)) {
App.toast(this, "The end is before the start.");
return;
}
if (calHref == null) {
App.toast(this, "No writable calendar. Sync first.");
return;
}
String t = txt(title);
new SaveTask(t.length() > 0 ? t : "(no title)", txt(location), notes.getText().toString().trim(), s, e, ad).execute();
}
// ----------------------------------------------------------- network
class LoadTask extends AsyncTask<Void, Void, Object> {
ProgressDialog pd;
@Override
protected void onPreExecute() {
pd = ProgressDialog.show(EditActivity.this, null, "Loading…");
}
@Override
protected Object doInBackground(Void... v) {
Dav d = null;
try {
d = App.dav(EditActivity.this);
return d.get(editing.href);
} catch (Throwable t) {
return t;
} finally {
if (d != null) d.close();
}
}
@Override
protected void onPostExecute(Object r) {
try {
pd.dismiss();
} catch (RuntimeException ignored) {
}
if (r instanceof Throwable) {
fail("Can't load event", App.describe((Throwable) r));
return;
}
String[] got = (String[]) r;
ics = got[0];
etag = got[1] != null ? got[1] : editing.etag;
if (ICal.isRecurring(ics)) {
fail("Repeating event", "Repeating events can't be edited on the phone yet. Edit the series in Nextcloud.");
return;
}
List<ICal.VEvent> vs = ICal.parse(ics);
if (vs.isEmpty()) {
fail("Can't edit", "The server returned no event data.");
return;
}
ICal.VEvent v = vs.get(0);
alarm = v.alarmMin;
originalAlarm = v.alarmMin;
fill(v.summary, v.location, v.description, v.start, v.end, v.allDay);
save.setEnabled(true);
}
}
void fail(String t, String msg) {
new AlertDialog.Builder(this).setTitle(t).setMessage(msg)
.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
finish();
}
}).show();
}
class SaveTask extends AsyncTask<Void, Void, Throwable> {
final String t, loc, desc;
final long s, e;
final boolean ad;
ProgressDialog pd;
SaveTask(String t, String loc, String desc, long s, long e, boolean ad) {
this.t = t;
this.loc = loc;
this.desc = desc;
this.s = s;
this.e = e;
this.ad = ad;
}
@Override
protected void onPreExecute() {
pd = ProgressDialog.show(EditActivity.this, null, "Saving…");
}
@Override
protected Throwable doInBackground(Void... v) {
Dav d = null;
try {
d = App.dav(EditActivity.this);
if (editing == null) {
String uid = UUID.randomUUID().toString().toUpperCase();
d.create(calHref, uid, ICal.newEvent(uid, t, loc, desc, s, e, ad, alarm));
} else {
int a = alarm == originalAlarm ? -2 : alarm;
d.update(editing.href, etag, ICal.modify(ics, t, loc, desc, s, e, ad, a));
}
return null;
} catch (Throwable x) {
return x;
} finally {
if (d != null) d.close();
}
}
@Override
protected void onPostExecute(Throwable x) {
try {
pd.dismiss();
} catch (RuntimeException ignored) {
}
if (x != null) {
new AlertDialog.Builder(EditActivity.this).setTitle("Not saved")
.setMessage(App.describe(x)).setPositiveButton("OK", null).show();
return;
}
if (editing == null) App.prefs(EditActivity.this).edit().putString("defcal", calHref).commit();
App.requestSync(EditActivity.this, true, false);
App.toast(EditActivity.this, "Saved");
finish();
}
}
}
+373
View File
@@ -0,0 +1,373 @@
package org.gingercal;
import java.util.ArrayList;
import java.util.List;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
/** Agenda: upcoming events grouped by day. */
public class MainActivity extends Activity {
static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4;
TextView status;
ListView list;
final List<Object> rows = new ArrayList<Object>();
final Agenda adapter = new Agenda();
boolean askedSetup;
final BroadcastReceiver rx = new BroadcastReceiver() {
@Override
public void onReceive(Context c, Intent i) {
String state = i.getStringExtra("state");
if ("start".equals(state)) {
showStatus();
return;
}
reload();
String fp = i.getStringExtra("fp");
if (fp != null) {
DavException.UntrustedCert u = new DavException.UntrustedCert(fp, i.getStringExtra("subject"),
i.getStringExtra("issuer"), i.getStringExtra("expires"), i.getBooleanExtra("mismatch", false));
App.askTrust(MainActivity.this, u, new App.Trusted() {
public void trusted(String f) {
App.prefs(MainActivity.this).edit().putString("pin", f).commit();
App.requestSync(MainActivity.this, true, false);
}
});
}
}
};
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setTitle("GingerCal");
LinearLayout root = App.vbox(this);
status = App.text(this, "", 12, 0xFFAAAAAA);
int p = App.dp(this, 6);
status.setPadding(p * 2, p, p * 2, p);
status.setBackgroundColor(0xFF1C1C1C);
root.addView(status, new LinearLayout.LayoutParams(-1, -2));
list = new ListView(this);
list.setAdapter(adapter);
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
public void onItemClick(AdapterView<?> parent, View v, int pos, long id) {
Object o = rows.get(pos);
if (o instanceof Ev) showEvent((Ev) o);
}
});
root.addView(list, new LinearLayout.LayoutParams(-1, -1));
setContentView(root);
App.scheduleSync(this);
}
@Override
protected void onResume() {
super.onResume();
registerReceiver(rx, new IntentFilter(SyncService.STATE));
if (!App.configured(this)) {
showStatus();
if (!askedSetup) {
askedSetup = true;
startActivity(new Intent(this, SettingsActivity.class));
}
return;
}
reload();
long last = App.prefs(this).getLong("lastsync", 0);
if (!SyncService.running && System.currentTimeMillis() - last > 15 * ICal.MIN) {
App.requestSync(this, true, false);
}
}
@Override
protected void onPause() {
super.onPause();
unregisterReceiver(rx);
}
void showStatus() {
if (!App.configured(this)) {
status.setText("Not set up yet: press Menu > Settings.");
return;
}
if (SyncService.running) {
status.setText("Syncing…");
status.setTextColor(0xFFAAAAAA);
return;
}
long last = App.prefs(this).getLong("lastsync", 0);
boolean err = App.prefs(this).getBoolean("error", false);
String s = App.get(this, "status");
String when = last == 0 ? "never" : App.day(last) + " " + App.time(this, last);
if (err) {
status.setText("Sync failed: " + s + "\nLast good sync: " + when);
status.setTextColor(0xFFFF8A80);
} else {
status.setText("Synced " + when + (s.length() > 0 ? " · " + s : ""));
status.setTextColor(0xFFAAAAAA);
}
}
void reload() {
rows.clear();
long today = ICal.startOfDay(System.currentTimeMillis());
List<Ev> evs = Db.get(this).agenda(today, ICal.addDays(today, App.ahead(this) + 1));
long lastDay = -1;
for (Ev e : evs) {
long d = Math.max(ICal.startOfDay(e.start), today);
if (d != lastDay) {
rows.add(Long.valueOf(d));
lastDay = d;
}
rows.add(e);
}
if (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync.");
adapter.notifyDataSetChanged();
showStatus();
}
// ---------------------------------------------------------------- menu
@Override
public boolean onCreateOptionsMenu(Menu m) {
icon(m.add(0, M_SYNC, 0, "Sync"), "ic_menu_refresh");
icon(m.add(0, M_NEW, 1, "New event"), "ic_menu_add");
icon(m.add(0, M_CALS, 2, "Calendars"), "ic_menu_agenda");
icon(m.add(0, M_SETTINGS, 3, "Settings"), "ic_menu_preferences");
return true;
}
void icon(MenuItem i, String name) {
int id = App.sysDrawable(this, name);
if (id != 0) i.setIcon(id);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case M_SYNC:
if (App.configured(this)) App.requestSync(this, true, false);
else startActivity(new Intent(this, SettingsActivity.class));
return true;
case M_NEW:
if (writableCalendars().isEmpty()) {
App.toast(this, "Sync first: no writable calendar yet.");
} else {
startActivity(new Intent(this, EditActivity.class));
}
return true;
case M_CALS:
startActivity(new Intent(this, CalendarsActivity.class));
return true;
case M_SETTINGS:
startActivity(new Intent(this, SettingsActivity.class));
return true;
}
return false;
}
List<Cal> writableCalendars() {
List<Cal> out = new ArrayList<Cal>();
for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) out.add(c);
return out;
}
// -------------------------------------------------------------- details
void showEvent(final Ev e) {
StringBuilder m = new StringBuilder();
m.append(App.day(e.start)).append("\n").append(App.when(this, e));
if (e.location.length() > 0) m.append("\n\n").append(e.location);
if (e.descr.length() > 0) m.append("\n\n").append(e.descr);
Cal cal = Db.get(this).calendars().get(e.cal);
if (cal != null) m.append("\n\nCalendar: ").append(cal.name);
if (e.recurring) m.append("\n\nRepeating event: edit the series in Nextcloud. Delete removes the whole series.");
AlertDialog.Builder b = new AlertDialog.Builder(this)
.setTitle(e.title.length() > 0 ? e.title : "(no title)")
.setMessage(m.toString())
.setNegativeButton("Close", null);
if (e.writable && !e.recurring) {
b.setPositiveButton("Edit", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
Intent i = new Intent(MainActivity.this, EditActivity.class);
i.putExtra("id", e.id);
startActivity(i);
}
});
}
if (e.writable) {
b.setNeutralButton("Delete", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
confirmDelete(e);
}
});
}
b.show();
}
void confirmDelete(final Ev e) {
new AlertDialog.Builder(this)
.setTitle("Delete event?")
.setMessage(e.recurring ? "This deletes EVERY occurrence of \"" + e.title + "\"." : "\"" + e.title + "\" will be deleted on the server.")
.setPositiveButton("Delete", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
new DeleteTask(e).execute();
}
})
.setNegativeButton("Cancel", null)
.show();
}
class DeleteTask extends AsyncTask<Void, Void, Throwable> {
final Ev e;
ProgressDialog pd;
DeleteTask(Ev e) {
this.e = e;
}
@Override
protected void onPreExecute() {
pd = ProgressDialog.show(MainActivity.this, null, "Deleting…");
}
@Override
protected Throwable doInBackground(Void... v) {
Dav d = null;
try {
d = App.dav(MainActivity.this);
d.delete(e.href, e.etag);
return null;
} catch (Throwable t) {
return t;
} finally {
if (d != null) d.close();
}
}
@Override
protected void onPostExecute(Throwable t) {
try {
pd.dismiss();
} catch (RuntimeException ignored) {
}
if (t != null) {
new AlertDialog.Builder(MainActivity.this).setTitle("Delete failed")
.setMessage(App.describe(t)).setPositiveButton("OK", null).show();
return;
}
App.requestSync(MainActivity.this, true, false);
for (int i = rows.size() - 1; i >= 0; i--) {
Object o = rows.get(i);
if (o instanceof Ev && ((Ev) o).href.equals(e.href)) rows.remove(i);
}
adapter.notifyDataSetChanged();
}
}
// ------------------------------------------------------------- adapter
static class Holder {
View bar;
TextView time, title, where;
}
class Agenda extends BaseAdapter {
public int getCount() {
return rows.size();
}
public Object getItem(int p) {
return rows.get(p);
}
public long getItemId(int p) {
return p;
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int p) {
return rows.get(p) instanceof Ev ? 1 : 0;
}
@Override
public boolean areAllItemsEnabled() {
return false;
}
@Override
public boolean isEnabled(int p) {
return rows.get(p) instanceof Ev;
}
public View getView(int p, View convert, ViewGroup parent) {
Object o = rows.get(p);
Context c = MainActivity.this;
if (!(o instanceof Ev)) {
TextView h = convert instanceof TextView ? (TextView) convert : App.text(c, "", 13, 0xFFFFFFFF);
int pad = App.dp(c, 6);
h.setPadding(pad * 2, pad, pad, pad);
h.setBackgroundColor(0xFF2E3B44);
h.setText(o instanceof Long ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o));
return h;
}
Ev e = (Ev) o;
Holder hd;
View v = convert;
if (v == null || !(v.getTag() instanceof Holder)) {
hd = new Holder();
LinearLayout row = new LinearLayout(c);
row.setOrientation(LinearLayout.HORIZONTAL);
hd.bar = new View(c);
row.addView(hd.bar, new LinearLayout.LayoutParams(App.dp(c, 5), -1));
LinearLayout col = App.vbox(c);
int pad = App.dp(c, 6);
col.setPadding(pad * 2, pad, pad, pad);
hd.time = App.text(c, "", 12, 0xFFB0BEC5);
hd.title = App.text(c, "", 16, 0xFFFFFFFF);
hd.where = App.text(c, "", 12, 0xFF90A4AE);
col.addView(hd.time);
col.addView(hd.title);
col.addView(hd.where);
row.addView(col, new LinearLayout.LayoutParams(-1, -2));
row.setTag(hd);
v = row;
} else {
hd = (Holder) v.getTag();
}
hd.bar.setBackgroundColor(e.color);
hd.time.setText(App.when(c, e) + (e.recurring ? " (repeats)" : ""));
hd.title.setText(e.title.length() > 0 ? e.title : "(no title)");
hd.where.setText(e.location);
hd.where.setVisibility(e.location.length() > 0 ? View.VISIBLE : View.GONE);
return v;
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package org.gingercal;
import java.util.List;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import org.gingercal.Model.Ev;
/** Local notifications before timed events, driven from the cached agenda. */
public final class Reminders {
private Reminders() {}
static PendingIntent alarmIntent(Context c) {
Intent i = new Intent(c, AlarmReceiver.class);
i.setAction(AlarmReceiver.REMIND);
return PendingIntent.getBroadcast(c, 2, i, 0x08000000 /* FLAG_UPDATE_CURRENT */);
}
public static void schedule(Context c) {
AlarmManager am = (AlarmManager) c.getSystemService("alarm");
PendingIntent pi = alarmIntent(c);
am.cancel(pi);
int m = App.remind(c);
if (m < 0) return;
long now = System.currentTimeMillis();
long notified = App.prefs(c).getLong("notified", 0);
long next = Db.get(c).nextStartAfter(Math.max(notified, now - 60000L));
if (next < 0) return;
am.set(0 /* RTC_WAKEUP */, Math.max(now + 1000, next - m * ICal.MIN), pi);
}
public static void fire(Context c) {
int m = App.remind(c);
if (m < 0) return;
long now = System.currentTimeMillis();
long notified = App.prefs(c).getLong("notified", 0);
// never nag about things that started more than 30 minutes ago
List<Ev> due = Db.get(c).startingBetween(Math.max(notified, now - 30 * ICal.MIN), now + m * ICal.MIN + 30000L);
NotificationManager nm = (NotificationManager) c.getSystemService("notification");
long newest = notified;
int icon = App.sysDrawable(c, "ic_popup_reminder");
if (icon == 0) icon = App.sysDrawable(c, "ic_dialog_info");
for (Ev e : due) {
Intent open = new Intent(c, MainActivity.class);
open.setFlags(0x10000000 /* FLAG_ACTIVITY_NEW_TASK */);
PendingIntent pi = PendingIntent.getActivity(c, 0, open, 0);
Notification n = new Notification(icon, e.title, e.start);
String body = App.when(c, e) + (e.location.length() > 0 ? " · " + e.location : "");
n.setLatestEventInfo(c, e.title.length() > 0 ? e.title : "(no title)", body, pi);
n.flags |= 16; // FLAG_AUTO_CANCEL
n.defaults = -1; // DEFAULT_ALL: sound, vibrate, lights
nm.notify((e.href + e.start).hashCode(), n);
if (e.start > newest) newest = e.start;
}
if (newest != notified) App.prefs(c).edit().putLong("notified", newest).commit();
schedule(c);
}
}
+243
View File
@@ -0,0 +1,243 @@
package org.gingercal;
import java.util.List;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.gingercal.Model.Cal;
/** Account, TLS pin, sync interval and reminder settings. */
public class SettingsActivity extends Activity {
EditText url, user, pass, pin, ahead;
Button intervalBtn, remindBtn;
int interval, remind;
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setTitle("GingerCal settings");
LinearLayout f = App.vbox(this);
int p = App.dp(this, 8);
f.setPadding(p, p, p, p);
f.addView(App.text(this, "Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your "
+ "legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home "
+ "Wi-Fi. Use an app password, not your real one.", 12, 0xFFB0BEC5));
url = field(f, "Server address", "https://cloud.example.com:8443", 1 | 0x10 /* URI */);
user = field(f, "User name", "", 1 | 0x80000 /* no suggestions */);
pass = field(f, "App password", "", 1 | 0x80 /* password */);
pin = field(f, "Pinned certificate SHA-256 (filled in for you)", "optional", 1 | 0x80000);
ahead = field(f, "Days ahead to sync", "60", 2 /* number */);
intervalBtn = new Button(this);
f.addView(intervalBtn, new LinearLayout.LayoutParams(-1, -2));
remindBtn = new Button(this);
f.addView(remindBtn, new LinearLayout.LayoutParams(-1, -2));
Button test = new Button(this);
test.setText("Save and test connection");
f.addView(test, new LinearLayout.LayoutParams(-1, -2));
Button resync = new Button(this);
resync.setText("Re-download everything");
f.addView(resync, new LinearLayout.LayoutParams(-1, -2));
ScrollView sv = new ScrollView(this);
sv.addView(f);
setContentView(sv);
url.setText(App.get(this, "url"));
user.setText(App.get(this, "user"));
pass.setText(App.get(this, "pass"));
pin.setText(App.get(this, "pin"));
ahead.setText(String.valueOf(App.ahead(this)));
interval = App.interval(this);
remind = App.remind(this);
labels();
intervalBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
final String[] l = new String[App.INTERVALS.length];
for (int i = 0; i < l.length; i++) l[i] = intervalLabel(App.INTERVALS[i]);
new AlertDialog.Builder(SettingsActivity.this).setTitle("Background sync")
.setItems(l, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
interval = App.INTERVALS[w];
labels();
}
}).show();
}
});
remindBtn.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
final int[] opts = {-1, 0, 5, 10, 15, 30, 60};
String[] l = new String[opts.length];
for (int i = 0; i < l.length; i++) l[i] = opts[i] < 0 ? "Off" : App.alarmLabel(opts[i]);
new AlertDialog.Builder(SettingsActivity.this).setTitle("Phone notification")
.setItems(l, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
remind = opts[w];
labels();
}
}).show();
}
});
test.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
confirmAndTest();
}
});
resync.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
save();
Db.get(SettingsActivity.this).forgetSyncState();
App.requestSync(SettingsActivity.this, true, true);
App.toast(SettingsActivity.this, "Re-downloading in the background");
finish();
}
});
}
EditText field(LinearLayout f, String label, String hint, int type) {
TextView l = App.text(this, label, 13, 0xFFFFFFFF);
l.setPadding(App.dp(this, 4), App.dp(this, 8), 0, 0);
f.addView(l);
EditText e = new EditText(this);
e.setHint(hint);
e.setSingleLine(true);
e.setInputType(type);
f.addView(e, new LinearLayout.LayoutParams(-1, -2));
return e;
}
static String intervalLabel(int h) {
return h == 0 ? "Manual only" : h == 1 ? "Every hour" : "Every " + h + " hours";
}
void labels() {
intervalBtn.setText("Background sync: " + intervalLabel(interval));
remindBtn.setText("Notify: " + (remind < 0 ? "off" : App.alarmLabel(remind)));
}
static String txt(TextView t) {
return t.getText().toString().trim();
}
@Override
protected void onPause() {
super.onPause();
if (txt(url).length() > 0) save();
}
void save() {
String oldAccount = App.get(this, "url") + "|" + App.get(this, "user");
int days = 60;
try {
days = Math.max(7, Math.min(365, Integer.parseInt(txt(ahead))));
} catch (NumberFormatException ignored) {
}
String u = txt(url);
if (u.length() > 0) u = Dav.normalize(u);
App.prefs(this).edit()
.putString("url", u)
.putString("user", txt(user))
.putString("pass", pass.getText().toString())
.putString("pin", Tls.normPin(txt(pin)).length() == 64 ? txt(pin) : "")
.putInt("ahead", days)
.putInt("interval", interval)
.putInt("remind", remind)
.commit();
if (!oldAccount.equals(u + "|" + txt(user))) {
Db.get(this).clearAll();
App.prefs(this).edit().putLong("lastsync", 0).putString("status", "").commit();
}
App.scheduleSync(this);
Reminders.schedule(this);
}
void confirmAndTest() {
String u = txt(url);
if (u.length() == 0 || txt(user).length() == 0) {
App.toast(this, "Server address and user name are required.");
return;
}
if (Dav.normalize(u).startsWith("http://") && !App.isLan(u)) {
new AlertDialog.Builder(this).setTitle("Unencrypted connection")
.setMessage("http:// sends your password in clear text over the internet. Use the legacy-TLS "
+ "bridge instead, or only use http:// to a LAN address.")
.setPositiveButton("Use anyway", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
save();
new TestTask().execute();
}
})
.setNegativeButton("Cancel", null).show();
return;
}
save();
new TestTask().execute();
}
class TestTask extends AsyncTask<Void, Void, Object> {
ProgressDialog pd;
@Override
protected void onPreExecute() {
pd = ProgressDialog.show(SettingsActivity.this, null, "Connecting…");
}
@Override
protected Object doInBackground(Void... v) {
Dav d = null;
try {
d = App.dav(SettingsActivity.this);
return d.calendars(d.calendarHome());
} catch (Throwable t) {
return t;
} finally {
if (d != null) d.close();
}
}
@SuppressWarnings("unchecked")
@Override
protected void onPostExecute(Object r) {
try {
pd.dismiss();
} catch (RuntimeException ignored) {
}
if (r instanceof DavException.UntrustedCert) {
App.askTrust(SettingsActivity.this, (DavException.UntrustedCert) r, new App.Trusted() {
public void trusted(String fp) {
pin.setText(fp);
save();
new TestTask().execute();
}
});
return;
}
if (r instanceof Throwable) {
new AlertDialog.Builder(SettingsActivity.this).setTitle("Connection failed")
.setMessage(App.describe((Throwable) r)).setPositiveButton("OK", null).show();
return;
}
List<Cal> cals = (List<Cal>) r;
StringBuilder s = new StringBuilder("Connected. Found " + cals.size() + " calendar(s):\n");
for (Cal c : cals) s.append("\n• ").append(c.name).append(c.writable ? "" : " (read-only)");
new AlertDialog.Builder(SettingsActivity.this).setTitle("It works")
.setMessage(s.toString())
.setPositiveButton("Sync now", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int w) {
App.requestSync(SettingsActivity.this, true, false);
finish();
}
}).show();
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package org.gingercal;
import android.app.IntentService;
import android.content.Intent;
import android.content.SharedPreferences;
/** Runs a sync on a worker thread and broadcasts the outcome to whatever screen is open. */
public class SyncService extends IntentService {
public static final String STATE = "org.gingercal.SYNC_STATE";
public static volatile boolean running;
public SyncService() {
super("GingerCalSync");
}
@Override
protected void onHandleIntent(Intent intent) {
boolean manual = intent != null && intent.getBooleanExtra("manual", false);
boolean force = intent != null && intent.getBooleanExtra("force", false);
SharedPreferences p = App.prefs(this);
Dav dav = null;
try {
if (!App.configured(this)) return;
if (!manual && !App.online(this)) return;
running = true;
send("start", null, null);
dav = App.dav(this);
long now = System.currentTimeMillis();
Syncer.Result r = Syncer.sync(dav, Db.get(this), now, 1, App.ahead(this), force);
p.edit().putLong("lastsync", now).putString("status", r.summary()).putBoolean("error", false).commit();
send("done", r.summary(), null);
} catch (DavException.UntrustedCert u) {
p.edit().putString("status", u.getMessage()).putBoolean("error", true).commit();
send("error", u.getMessage(), u);
} catch (Throwable t) {
String msg = App.describe(t);
p.edit().putString("status", msg).putBoolean("error", true).commit();
send("error", msg, null);
} finally {
running = false;
if (dav != null) dav.close();
try {
Reminders.schedule(this);
} catch (RuntimeException ignored) {
}
AlarmReceiver.release();
}
}
void send(String state, String msg, DavException.UntrustedCert u) {
Intent b = new Intent(STATE);
b.setPackage(getPackageName());
b.putExtra("state", state);
if (msg != null) b.putExtra("msg", msg);
if (u != null) {
b.putExtra("fp", u.fingerprint);
b.putExtra("subject", u.subject);
b.putExtra("issuer", u.issuer);
b.putExtra("expires", u.expires);
b.putExtra("mismatch", u.pinMismatch);
}
sendBroadcast(b);
}
}