From ec690e9595347a6ff896e263670ff2d6f96a9a7b Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 3 May 2017 18:58:41 +0200 Subject: [PATCH] Fix #13 -- Add asynchronous scanning Add greenDAO More settings foo, SyncService Download data Offline scanning Upload scan queue Auto sync Ignore missing translations during linting be more web 2.0 API version handling Switch from greenDAO to requery Improve synchronization speed Prevent race condition between threads Display sync status --- pretixdroid/app/build.gradle | 4 + pretixdroid/app/src/main/AndroidManifest.xml | 4 + .../java/eu/pretix/pretixdroid/AppConfig.java | 62 +++++- .../eu/pretix/pretixdroid/PretixDroid.java | 31 ++- .../pretixdroid/async/SyncException.java | 8 + .../pretix/pretixdroid/async/SyncService.java | 201 ++++++++++++++++++ .../pretixdroid/check/AsyncCheckProvider.java | 107 ++++++++++ .../pretixdroid/db/AbstractQueuedCheckIn.java | 25 +++ .../pretix/pretixdroid/db/AbstractTicket.java | 34 +++ .../pretix/pretixdroid/db/NonceGenerator.java | 13 ++ .../pretix/pretixdroid/net/api/PretixApi.java | 51 ++++- .../pretix/pretixdroid/ui/MainActivity.java | 128 ++++++++++- .../pretix/pretixdroid/ui/SearchActivity.java | 12 +- .../pretixdroid/ui/SettingsFragment.java | 83 +++++++- .../app/src/main/res/layout/activity_main.xml | 28 ++- .../app/src/main/res/values-de/strings.xml | 25 +++ .../app/src/main/res/values/strings.xml | 28 ++- .../app/src/main/res/xml/preferences.xml | 6 + 18 files changed, 827 insertions(+), 23 deletions(-) create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncException.java create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncService.java create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/check/AsyncCheckProvider.java create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractQueuedCheckIn.java create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractTicket.java create mode 100644 pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/NonceGenerator.java diff --git a/pretixdroid/app/build.gradle b/pretixdroid/app/build.gradle index e5daf77..004300e 100644 --- a/pretixdroid/app/build.gradle +++ b/pretixdroid/app/build.gradle @@ -15,6 +15,7 @@ android { lintOptions { disable 'InvalidPackage' // problem with jetty and bouncycastle + disable 'MissingTranslation' } signingConfigs { @@ -70,6 +71,9 @@ dependencies { compile 'com.android.support:support-v4:25.3.1' compile 'com.android.support:support-vector-drawable:25.3.1' compile 'com.joshdholtz.sentry:sentry-android:1.6.0' + compile 'io.requery:requery:1.3.1' + compile 'io.requery:requery-android:1.3.1' + annotationProcessor 'io.requery:requery-processor:1.3.1' compile 'com.facebook.stetho:stetho:1.5.0' compile 'com.facebook.stetho:stetho-okhttp3:1.5.0' } diff --git a/pretixdroid/app/src/main/AndroidManifest.xml b/pretixdroid/app/src/main/AndroidManifest.xml index b0f6cac..a816c46 100644 --- a/pretixdroid/app/src/main/AndroidManifest.xml +++ b/pretixdroid/app/src/main/AndroidManifest.xml @@ -43,6 +43,10 @@ android:name="android.support.PARENT_ACTIVITY" android:value="eu.pretix.pretixdroid.ui.MainActivity" /> + + \ No newline at end of file diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/AppConfig.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/AppConfig.java index 280145b..1b2adec 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/AppConfig.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/AppConfig.java @@ -4,14 +4,22 @@ import android.content.SharedPreferences; import android.preference.PreferenceManager; +import eu.pretix.pretixdroid.net.api.PretixApi; + public class AppConfig { public static final String PREFS_NAME = "pretixdroid"; public static final String PREFS_KEY_API_URL = "pretix_api_url"; public static final String PREFS_KEY_API_KEY = "pretix_api_key"; + public static final String PREFS_KEY_API_VERSION = "pretix_api_version"; public static final String PREFS_KEY_FLASHLIGHT = "flashlight"; public static final String PREFS_KEY_AUTOFOCUS = "autofocus"; public static final String PREFS_KEY_CAMERA = "camera"; + public static final String PREFS_KEY_ASYNC_MODE = "async"; public static final String PREFS_PLAY_AUDIO = "playaudio"; + public static final String PREFS_KEY_LAST_SYNC = "last_sync"; + public static final String PREFS_KEY_LAST_FAILED_SYNC = "last_failed_sync"; + public static final String PREFS_KEY_LAST_FAILED_SYNC_MSG = "last_failed_sync_msg"; + public static final String PREFS_KEY_LAST_DOWNLOAD = "last_download"; private SharedPreferences prefs; private SharedPreferences default_prefs; @@ -24,10 +32,14 @@ public boolean isConfigured() { return prefs.contains(PREFS_KEY_API_URL); } - public void setEventConfig(String url, String key) { + public void setEventConfig(String url, String key, int version) { prefs.edit() .putString(PREFS_KEY_API_URL, url) .putString(PREFS_KEY_API_KEY, key) + .putInt(PREFS_KEY_API_VERSION, version) + .remove(PREFS_KEY_LAST_DOWNLOAD) + .remove(PREFS_KEY_LAST_SYNC) + .remove(PREFS_KEY_LAST_FAILED_SYNC) .apply(); } @@ -35,9 +47,17 @@ public void resetEventConfig() { prefs.edit() .remove(PREFS_KEY_API_URL) .remove(PREFS_KEY_API_KEY) + .remove(PREFS_KEY_API_VERSION) + .remove(PREFS_KEY_LAST_DOWNLOAD) + .remove(PREFS_KEY_LAST_SYNC) + .remove(PREFS_KEY_LAST_FAILED_SYNC) .apply(); } + public int getApiVersion() { + return prefs.getInt(PREFS_KEY_API_VERSION, PretixApi.SUPPORTED_API_VERSION); + } + public String getApiUrl() { return prefs.getString(PREFS_KEY_API_URL, ""); } @@ -77,4 +97,44 @@ public void setSoundEnabled(boolean val) { public void setCamera(boolean val) { default_prefs.edit().putBoolean(PREFS_KEY_CAMERA, val).apply(); } + + public boolean getAsyncModeEnabled() { + return default_prefs.getBoolean(PREFS_KEY_ASYNC_MODE, false); + } + + public void setAsyncModeEnabled(boolean val) { + default_prefs.edit().putBoolean(PREFS_KEY_ASYNC_MODE, val).apply(); + } + + public long getLastDownload() { + return prefs.getLong(PREFS_KEY_LAST_DOWNLOAD, 0); + } + + public void setLastDownload(long val) { + prefs.edit().putLong(PREFS_KEY_LAST_DOWNLOAD, val).apply(); + } + + public long getLastSync() { + return prefs.getLong(PREFS_KEY_LAST_SYNC, 0); + } + + public void setLastSync(long val) { + prefs.edit().putLong(PREFS_KEY_LAST_SYNC, val).apply(); + } + + public long getLastFailedSync() { + return prefs.getLong(PREFS_KEY_LAST_FAILED_SYNC, 0); + } + + public void setLastFailedSync(long val) { + prefs.edit().putLong(PREFS_KEY_LAST_FAILED_SYNC, val).apply(); + } + + public String getLastFailedSyncMsg() { + return prefs.getString(PREFS_KEY_LAST_FAILED_SYNC_MSG, ""); + } + + public void setLastFailedSyncMsg(String val) { + prefs.edit().putString(PREFS_KEY_LAST_FAILED_SYNC_MSG, val).apply(); + } } diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/PretixDroid.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/PretixDroid.java index 295c028..2a4c95e 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/PretixDroid.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/PretixDroid.java @@ -4,6 +4,17 @@ import com.facebook.stetho.Stetho; + +import eu.pretix.pretixdroid.check.AsyncCheckProvider; +import eu.pretix.pretixdroid.check.OnlineCheckProvider; +import eu.pretix.pretixdroid.check.TicketCheckProvider; +import eu.pretix.pretixdroid.db.Models; +import io.requery.BlockingEntityStore; +import io.requery.Persistable; +import io.requery.android.sqlite.DatabaseSource; +import io.requery.sql.Configuration; +import io.requery.sql.EntityDataStore; + public class PretixDroid extends Application { /* * It is not a security problem that the keystore password is hardcoded in plain text. @@ -12,6 +23,7 @@ public class PretixDroid extends Application { * screwed either way. */ public static final String KEYSTORE_PASSWORD = "ZnDNUkQ01PVZyD7oNP3a8DVXrvltxD"; + private BlockingEntityStore dataStore; @Override public void onCreate() { @@ -22,7 +34,22 @@ public void onCreate() { } } - public DaoSession getDaoSession() { - return daoSession; + public BlockingEntityStore getData() { + if (dataStore == null) { + // override onUpgrade to handle migrating to a new version + DatabaseSource source = new DatabaseSource(this, Models.DEFAULT, 1); + Configuration configuration = source.getConfiguration(); + dataStore = new EntityDataStore(configuration); + } + return dataStore; + } + + public TicketCheckProvider getNewCheckProvider() { + AppConfig config = new AppConfig(this); + if (config.getAsyncModeEnabled()) { + return new AsyncCheckProvider(this); + } else { + return new OnlineCheckProvider(this); + } } } diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncException.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncException.java new file mode 100644 index 0000000..f20f197 --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncException.java @@ -0,0 +1,8 @@ +package eu.pretix.pretixdroid.async; + + +public class SyncException extends Exception { + public SyncException(String msg) { + super(msg); + } +} diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncService.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncService.java new file mode 100644 index 0000000..fd680eb --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/async/SyncService.java @@ -0,0 +1,201 @@ +package eu.pretix.pretixdroid.async; + +import android.app.IntentService; +import android.content.Intent; +import android.util.Log; + +import com.joshdholtz.sentry.Sentry; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import eu.pretix.pretixdroid.AppConfig; +import eu.pretix.pretixdroid.PretixDroid; +import eu.pretix.pretixdroid.db.QueuedCheckIn; +import eu.pretix.pretixdroid.db.Ticket; +import eu.pretix.pretixdroid.net.api.ApiException; +import eu.pretix.pretixdroid.net.api.PretixApi; +import io.requery.BlockingEntityStore; +import io.requery.Persistable; +import io.requery.util.CloseableIterator; + +public class SyncService extends IntentService { + + private AppConfig config; + private PretixApi api; + private BlockingEntityStore dataStore; + + public SyncService() { + super("SyncService"); + } + + @Override + public void onCreate() { + super.onCreate(); + config = new AppConfig(this); + dataStore = ((PretixDroid) getApplicationContext()).getData(); + } + + @Override + protected void onHandleIntent(Intent workIntent) { + Log.i("SyncService", "Sync triggered"); + + // Rebuild in case config has changed + api = PretixApi.fromConfig(config); + + if (!config.isConfigured()) { + return; + } + + long upload_interval = 1000; + long download_interval = 30000; + if (!config.getAsyncModeEnabled()) { + download_interval = 120000; + } + + if ((System.currentTimeMillis() - config.getLastSync()) < upload_interval) { + return; + } + if ((System.currentTimeMillis() - config.getLastFailedSync()) < 30000) { + return; + } + + try { + uploadTicketData(); + + if ((System.currentTimeMillis() - config.getLastDownload()) > download_interval) { + downloadTicketData(); + config.setLastDownload(System.currentTimeMillis()); + } + + config.setLastSync(System.currentTimeMillis()); + config.setLastFailedSync(0); + } catch (SyncException e) { + config.setLastFailedSync(System.currentTimeMillis()); + config.setLastFailedSyncMsg(e.getMessage()); + } + } + + private void uploadTicketData() throws SyncException { + Sentry.addBreadcrumb("sync.queue", "Start upload"); + + List queued = dataStore.select(QueuedCheckIn.class).get().toList(); + + try { + for (QueuedCheckIn qci : queued) { + JSONObject response = api.redeem(qci.getSecret(), qci.getDatetime(), true, qci.getNonce()); + String status = response.getString("status"); + if ("ok".equals(status)) { + dataStore.delete(qci); + } else { + String reason = response.optString("reason"); + if ("already_redeemed".equals(reason)) { + // Well, we can't really do something about this. + dataStore.delete(qci); + } // Else: Retry later + } + } + } catch (JSONException e) { + Sentry.captureException(e); + throw new SyncException("Unknown server response"); + } catch (ApiException e) { + Sentry.addBreadcrumb("sync.tickets", "API Error: " + e.getMessage()); + throw new SyncException(e.getMessage()); + } + Sentry.addBreadcrumb("sync.queue", "Upload complete"); + } + + private static boolean string_changed(String newstring, String oldstring) { + return (newstring != null && oldstring == null) + || (newstring == null && oldstring != null) + || (newstring != null && oldstring != null && !newstring.equals(oldstring)); + } + + private void downloadTicketData() throws SyncException { + Sentry.addBreadcrumb("sync.tickets", "Start download"); + + // Download objects from server + JSONObject response; + try { + response = api.download(); + } catch (ApiException e) { + Sentry.addBreadcrumb("sync.tickets", "API Error: " + e.getMessage()); + throw new SyncException(e.getMessage()); + } + + // Index all known objects + Map known = new HashMap<>(); + CloseableIterator tickets = dataStore.select(Ticket.class).get().iterator(); + try { + while (tickets.hasNext()) { + Ticket t = tickets.next(); + known.put(t.getSecret(), t); + } + } finally { + tickets.close(); + } + + try { + List inserts = new ArrayList<>(); + // Insert or update + for (int i = 0; i < response.getJSONArray("results").length(); i++) { + JSONObject res = response.getJSONArray("results").getJSONObject(i); + + Ticket ticket; + boolean created = false; + if (!known.containsKey(res.getString("secret"))) { + ticket = new Ticket(); + created = true; + } else { + ticket = known.get(res.getString("secret")); + } + + if (string_changed(res.getString("attendee_name"), ticket.getAttendee_name())) { + ticket.setAttendee_name(res.getString("attendee_name")); + } + if (string_changed(res.getString("item"), ticket.getItem())) { + ticket.setItem(res.getString("item")); + } + if (string_changed(res.getString("variation"), ticket.getVariation())) { + ticket.setVariation(res.getString("variation")); + } + if (string_changed(res.getString("order"), ticket.getOrder())) { + ticket.setOrder(res.getString("order")); + } + if (string_changed(res.getString("secret"), ticket.getSecret())) { + ticket.setSecret(res.getString("secret")); + } + if (res.getBoolean("redeemed") != ticket.isRedeemed()) { + ticket.setRedeemed(res.getBoolean("redeemed")); + } + if (res.getBoolean("paid") != ticket.isPaid()) { + ticket.setPaid(res.getBoolean("paid")); + } + + if (created) { + inserts.add(ticket); + } else { + dataStore.update(ticket); + } + known.remove(res.getString("secret")); + } + + dataStore.insert(inserts); + } catch (JSONException e) { + Sentry.captureException(e); + throw new SyncException("Unknown server response"); + } + + // Those have been deleted online, delete them here as well + for (String key : known.keySet()) { + dataStore.delete(known.get(key)); + } + Sentry.addBreadcrumb("sync.tickets", "Download complete"); + } + +} diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/check/AsyncCheckProvider.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/check/AsyncCheckProvider.java new file mode 100644 index 0000000..f42b31b --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/check/AsyncCheckProvider.java @@ -0,0 +1,107 @@ +package eu.pretix.pretixdroid.check; + +import android.content.Context; + +import com.joshdholtz.sentry.Sentry; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import eu.pretix.pretixdroid.AppConfig; +import eu.pretix.pretixdroid.PretixDroid; +import eu.pretix.pretixdroid.db.QueuedCheckIn; +import eu.pretix.pretixdroid.db.Ticket; +import eu.pretix.pretixdroid.net.api.PretixApi; +import io.requery.BlockingEntityStore; +import io.requery.Persistable; + +public class AsyncCheckProvider implements TicketCheckProvider { + private Context ctx; + private PretixApi api; + private AppConfig config; + private BlockingEntityStore dataStore; + + public AsyncCheckProvider(Context ctx) { + this.ctx = ctx; + + this.config = new AppConfig(ctx); + this.api = PretixApi.fromConfig(config); + dataStore = ((PretixDroid) ctx.getApplicationContext()).getData(); + } + + @Override + public CheckResult check(String ticketid) { + Sentry.addBreadcrumb("provider.check", "offline check started"); + + List tickets = dataStore.select(Ticket.class) + .where(Ticket.SECRET.eq(ticketid)) + .get().toList(); + + if (tickets.size() == 0) { + return new CheckResult(CheckResult.Type.INVALID); + } + + Ticket ticket = tickets.get(0); + CheckResult res = new CheckResult(CheckResult.Type.ERROR); + + long queuedCheckIns = dataStore.count(QueuedCheckIn.class) + .where(QueuedCheckIn.SECRET.eq(ticketid)) + .get().value(); + + if (!ticket.isPaid()) { + res.setType(CheckResult.Type.UNPAID); + } else if (ticket.isRedeemed() || queuedCheckIns > 0) { + res.setType(CheckResult.Type.USED); + } else { + res.setType(CheckResult.Type.VALID); + ticket.setRedeemed(true); + dataStore.update(ticket); + + QueuedCheckIn qci = new QueuedCheckIn(); + qci.generateNonce(); + qci.setSecret(ticketid); + qci.setDatetime(new Date()); + dataStore.insert(qci); + } + + res.setTicket(ticket.getItem()); + res.setVariation(ticket.getVariation()); + res.setAttendee_name(ticket.getAttendee_name()); + res.setOrderCode(ticket.getOrder()); + return res; + } + + @Override + public List search(String query) throws CheckException { + Sentry.addBreadcrumb("provider.search", "offline search started"); + + List results = new ArrayList<>(); + + if (query.length() < 4) { + return results; + } + + List tickets = dataStore.select(Ticket.class) + .where( + Ticket.SECRET.like(query + "%") + .or(Ticket.ATTENDEE_NAME.like("%" + query + "%")) + .or(Ticket.ORDER.like(query + "%")) + ) + .limit(25) + .get().toList(); + + for (Ticket ticket : tickets) { + SearchResult sr = new SearchResult(); + sr.setTicket(ticket.getItem()); + sr.setVariation(ticket.getVariation()); + sr.setAttendee_name(ticket.getAttendee_name()); + sr.setOrderCode(ticket.getOrder()); + sr.setSecret(ticket.getSecret()); + sr.setRedeemed(ticket.isRedeemed()); + sr.setPaid(ticket.isPaid()); + results.add(sr); + } + return results; + } +} diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractQueuedCheckIn.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractQueuedCheckIn.java new file mode 100644 index 0000000..d28ceb9 --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractQueuedCheckIn.java @@ -0,0 +1,25 @@ +package eu.pretix.pretixdroid.db; + +import java.util.Date; + +import io.requery.Entity; +import io.requery.Generated; +import io.requery.Key; + +@Entity(cacheable = false) +public abstract class AbstractQueuedCheckIn { + + @Key + @Generated + public Long id; + + public String secret; + + public String nonce; + + public Date datetime; + + public void generateNonce() { + this.nonce = NonceGenerator.nextNonce(); + } +} diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractTicket.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractTicket.java new file mode 100644 index 0000000..45d833c --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/AbstractTicket.java @@ -0,0 +1,34 @@ +package eu.pretix.pretixdroid.db; + + +import io.requery.Column; +import io.requery.Entity; +import io.requery.Generated; +import io.requery.Key; +import io.requery.Nullable; + +@Entity(cacheable = false) +public abstract class AbstractTicket { + + @Generated + @Key + public Long id; + + @Column(unique = true) + public String secret; + + @Column(name = "order_code") + public String order; + + public String item; + + @Nullable + public String attendee_name; + + @Nullable + public String variation; + + public boolean redeemed; + + public boolean paid; +} diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/NonceGenerator.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/NonceGenerator.java new file mode 100644 index 0000000..46f73c0 --- /dev/null +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/db/NonceGenerator.java @@ -0,0 +1,13 @@ +package eu.pretix.pretixdroid.db; + + +import java.math.BigInteger; +import java.security.SecureRandom; + +public final class NonceGenerator { + private static SecureRandom random = new SecureRandom(); + + public static String nextNonce() { + return new BigInteger(130, random).toString(32); + } +} \ No newline at end of file diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/net/api/PretixApi.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/net/api/PretixApi.java index 2bae68d..14dcac6 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/net/api/PretixApi.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/net/api/PretixApi.java @@ -9,6 +9,11 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; import javax.net.ssl.SSLException; @@ -21,17 +26,23 @@ import okhttp3.Response; public class PretixApi { - public static final int API_VERSION = 2; + /** + * See https://docs.pretix.eu/en/latest/plugins/pretixdroid.html for API documentation + */ + + public static final int SUPPORTED_API_VERSION = 3; public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); private String url; private String key; + private int version; private OkHttpClient client; - public PretixApi(String url, String key) { + public PretixApi(String url, String key, int version) { this.url = url; this.key = key; + this.version = version; if (BuildConfig.DEBUG) { client = new OkHttpClient.Builder() .addNetworkInterceptor(new StethoInterceptor()) @@ -43,16 +54,31 @@ public PretixApi(String url, String key) { } public static PretixApi fromConfig(AppConfig config) { - return new PretixApi(config.getApiUrl(), config.getApiKey()); + return new PretixApi(config.getApiUrl(), config.getApiKey(), config.getApiVersion()); } public JSONObject redeem(String secret) throws ApiException { - RequestBody body = new FormBody.Builder() - .add("secret", secret) - .build(); + return redeem(secret, null, false, null); + } + + public JSONObject redeem(String secret, Date datetime, boolean force, String nonce) throws ApiException { + FormBody.Builder body = new FormBody.Builder() + .add("secret", secret); + if (datetime != null) { + TimeZone tz = TimeZone.getTimeZone("UTC"); + DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.ENGLISH); // Quoted "Z" to indicate UTC, no timezone offset + df.setTimeZone(tz); + body.add("datetime", df.format(datetime)); + } + if (force) { + body.add("force", "true"); + } + if (nonce != null) { + body.add("nonce", nonce); + } Request request = new Request.Builder() .url(url + "redeem/?key=" + key) - .post(body) + .post(body.build()) .build(); return apiCall(request); } @@ -70,6 +96,17 @@ public JSONObject search(String query) throws ApiException { return apiCall(request); } + public JSONObject download() throws ApiException { + if (version < 3) { + throw new ApiException("Unsupoorted in API versions lower than 3."); + } + Request request = new Request.Builder() + .url(url + "download/?key=" + key) + .get() + .build(); + return apiCall(request); + } + private JSONObject apiCall(Request request) throws ApiException { Response response; try { diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/MainActivity.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/MainActivity.java index ec43639..a72a510 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/MainActivity.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/MainActivity.java @@ -3,18 +3,21 @@ import android.Manifest; import android.content.BroadcastReceiver; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.res.AssetFileDescriptor; import android.media.AudioManager; import android.media.MediaPlayer; +import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.preference.PreferenceManager; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.util.Log; import android.view.KeyEvent; @@ -33,14 +36,20 @@ import org.json.JSONObject; import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Calendar; import java.util.List; +import java.util.Timer; +import java.util.TimerTask; import eu.pretix.pretixdroid.AppConfig; import eu.pretix.pretixdroid.BuildConfig; +import eu.pretix.pretixdroid.PretixDroid; import eu.pretix.pretixdroid.R; -import eu.pretix.pretixdroid.check.OnlineCheckProvider; +import eu.pretix.pretixdroid.async.SyncService; import eu.pretix.pretixdroid.check.TicketCheckProvider; +import eu.pretix.pretixdroid.db.QueuedCheckIn; import eu.pretix.pretixdroid.net.api.PretixApi; import me.dm7.barcodescanner.zxing.ZXingScannerView; @@ -59,6 +68,7 @@ public enum State { private MediaPlayer mediaPlayer; private TicketCheckProvider checkProvider; private AppConfig config; + private Timer timer; private BroadcastReceiver scanReceiver = new BroadcastReceiver() { @Override @@ -82,7 +92,7 @@ public void onCreate(Bundle savedInstanceState) { Sentry.init(this, BuildConfig.SENTRY_DSN); } - checkProvider = new OnlineCheckProvider(this); + checkProvider = ((PretixDroid) getApplication()).getNewCheckProvider(); config = new AppConfig(this); setContentView(R.layout.activity_main); @@ -109,6 +119,13 @@ public void onCreate(Bundle savedInstanceState) { timeoutHandler = new Handler(); + findViewById(R.id.rlSyncStaus).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + showSyncStatusDetails(); + } + }); + resetView(); getSupportActionBar().setDisplayShowHomeEnabled(true); @@ -136,6 +153,27 @@ public void onRequestPermissionsResult(int requestCode, } } + private class SyncTriggerTask extends TimerTask { + + @Override + public void run() { + triggerSync(); + } + } + + private class UpdateSyncStatusTask extends TimerTask { + + @Override + public void run() { + runOnUiThread(new Runnable() { + @Override + public void run() { + updateSyncStatus(); + } + }); + } + } + @Override public void onResume() { super.onResume(); @@ -150,6 +188,11 @@ public void onResume() { filter.addAction("scan.rcv.message"); registerReceiver(scanReceiver, filter); } + + timer = new Timer(); + timer.schedule(new SyncTriggerTask(), 1000, 10000); + timer.schedule(new UpdateSyncStatusTask(), 500, 500); + updateSyncStatus(); } @Override @@ -160,6 +203,7 @@ public void onPause() { } else { unregisterReceiver(scanReceiver); } + timer.cancel(); } @Override @@ -193,16 +237,22 @@ private void handleConfigScanned(String s) { try { JSONObject jsonObject = new JSONObject(s); - if (jsonObject.getInt("version") != PretixApi.API_VERSION) { + if (jsonObject.getInt("version") > PretixApi.SUPPORTED_API_VERSION) { displayScanResult(new TicketCheckProvider.CheckResult( TicketCheckProvider.CheckResult.Type.ERROR, getString(R.string.err_qr_version))); } else { - config.setEventConfig(jsonObject.getString("url"), jsonObject.getString("key")); - checkProvider = new OnlineCheckProvider(this); + if (jsonObject.getInt("version") < 3) { + config.setAsyncModeEnabled(false); + } + config.setEventConfig(jsonObject.getString("url"), jsonObject.getString("key"), + jsonObject.getInt("version")); + checkProvider = ((PretixDroid) getApplication()).getNewCheckProvider(); displayScanResult(new TicketCheckProvider.CheckResult( TicketCheckProvider.CheckResult.Type.VALID, getString(R.string.config_done))); + + triggerSync(); } } catch (JSONException e) { displayScanResult(new TicketCheckProvider.CheckResult( @@ -211,6 +261,11 @@ private void handleConfigScanned(String s) { } } + private void triggerSync() { + Intent i = new Intent(this, SyncService.class); + startService(i); + } + private void handleTicketScanned(String s) { Sentry.addBreadcrumb("main.scanned", "Ticket scanned"); @@ -220,6 +275,68 @@ private void handleTicketScanned(String s) { new CheckTask().execute(s); } + private void updateSyncStatus() { + if (config.getAsyncModeEnabled()) { + findViewById(R.id.rlSyncStaus).setVisibility(View.VISIBLE); + + if (config.getLastFailedSync() > config.getLastSync() || System.currentTimeMillis() - config.getLastDownload() > 5 * 60 * 1000) { + findViewById(R.id.rlSyncStaus).setBackgroundColor(ContextCompat.getColor(this, R.color.scan_result_err)); + } else { + findViewById(R.id.rlSyncStaus).setBackgroundColor(ContextCompat.getColor(this, R.color.scan_result_ok)); + } + String text; + long diff = System.currentTimeMillis() - config.getLastDownload(); + if (config.getLastDownload() == 0) { + text = getString(R.string.sync_status_never); + } else if (diff > 24 * 3600 * 1000) { + int days = (int) (diff / (24 * 3600 * 1000)); + text = getResources().getQuantityString(R.plurals.time_days, days, days); + } else if (diff > 3600 * 1000) { + int hours = (int) (diff / (3600 * 1000)); + text = getResources().getQuantityString(R.plurals.time_hours, hours, hours); + } else if (diff > 60 * 1000) { + int mins = (int) (diff / (60 * 1000)); + text = getResources().getQuantityString(R.plurals.time_minutes, mins, mins); + } else { + text = getString(R.string.sync_status_now); + } + + ((TextView) findViewById(R.id.tvSyncStatus)).setText(text); + } else { + findViewById(R.id.rlSyncStaus).setVisibility(View.GONE); + } + } + + public void showSyncStatusDetails() { + Calendar lastSync = Calendar.getInstance(); + lastSync.setTimeInMillis(config.getLastSync()); + Calendar lastSyncFailed = Calendar.getInstance(); + lastSyncFailed.setTimeInMillis(config.getLastFailedSync()); + long cnt = ((PretixDroid) getApplication()).getData().count(QueuedCheckIn.class).get().value(); + + SimpleDateFormat formatter = new SimpleDateFormat(getString(R.string.sync_status_date_format)); + new AlertDialog.Builder(this) + .setTitle(R.string.sync_status) + .setMessage( + getString(R.string.sync_status_last) + "\n" + + formatter.format(lastSync.getTime()) + "\n\n" + + getString(R.string.sync_status_local) + cnt + + (config.getLastFailedSync() > 0 ? ( + "\n\n" + + getString(R.string.sync_status_last_failed) + "\n" + + formatter.format(lastSyncFailed.getTime()) + + "\n" + config.getLastFailedSyncMsg() + ) : "") + + ) + .setPositiveButton(R.string.dismiss, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + } + }) + .show(); + + } + private void resetView() { TextView tvScanResult = (TextView) findViewById(R.id.tvScanResult); timeoutHandler.removeCallbacksAndMessages(null); @@ -261,6 +378,7 @@ protected TicketCheckProvider.CheckResult doInBackground(String... params) { @Override protected void onPostExecute(TicketCheckProvider.CheckResult checkResult) { displayScanResult(checkResult); + triggerSync(); } } diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SearchActivity.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SearchActivity.java index d207359..6042c8d 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SearchActivity.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SearchActivity.java @@ -2,6 +2,7 @@ import android.app.ProgressDialog; import android.content.DialogInterface; +import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; import android.support.v4.app.NavUtils; @@ -22,9 +23,10 @@ import java.util.ArrayList; import java.util.List; +import eu.pretix.pretixdroid.PretixDroid; import eu.pretix.pretixdroid.R; +import eu.pretix.pretixdroid.async.SyncService; import eu.pretix.pretixdroid.check.CheckException; -import eu.pretix.pretixdroid.check.OnlineCheckProvider; import eu.pretix.pretixdroid.check.TicketCheckProvider; public class SearchActivity extends AppCompatActivity { @@ -39,7 +41,7 @@ public class SearchActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - checkProvider = new OnlineCheckProvider(this); + checkProvider = ((PretixDroid) getApplication()).getNewCheckProvider(); setContentView(R.layout.activity_search); @@ -173,9 +175,15 @@ public void onClick(DialogInterface dialog, int which) { .show(); startSearch(etQuery.getText().toString()); + triggerSync(); } } + private void triggerSync() { + Intent i = new Intent(this, SyncService.class); + startService(i); + } + private void showList(List checkResult) { SearchResultAdapter adapter = new SearchResultAdapter( this, R.layout.listitem_searchresult, R.id.tvAttendeeName, checkResult); diff --git a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SettingsFragment.java b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SettingsFragment.java index 385b5f8..3e77064 100644 --- a/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SettingsFragment.java +++ b/pretixdroid/app/src/main/java/eu/pretix/pretixdroid/ui/SettingsFragment.java @@ -1,8 +1,11 @@ package eu.pretix.pretixdroid.ui; +import android.content.DialogInterface; import android.os.Bundle; +import android.preference.CheckBoxPreference; import android.preference.Preference; import android.preference.PreferenceFragment; +import android.preference.SwitchPreference; import android.support.annotation.RawRes; import android.support.annotation.StringRes; import android.support.v7.app.AlertDialog; @@ -21,9 +24,22 @@ import java.io.InputStreamReader; import eu.pretix.pretixdroid.AppConfig; +import eu.pretix.pretixdroid.PretixDroid; import eu.pretix.pretixdroid.R; +import eu.pretix.pretixdroid.db.QueuedCheckIn; public class SettingsFragment extends PreferenceFragment { + + private void resetApp() { +// DaoSession daoSession = ((PretixDroid) getActivity().getApplication()).getDaoSession(); +// daoSession.getQueuedCheckInDao().deleteAll(); +// daoSession.getTicketDao().deleteAll(); + + AppConfig config = new AppConfig(getActivity()); + config.resetEventConfig(); + Toast.makeText(getActivity(), R.string.reset_success, Toast.LENGTH_SHORT).show(); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -35,14 +51,31 @@ public void onCreate(Bundle savedInstanceState) { reset.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { @Override public boolean onPreferenceClick(Preference preference) { - AppConfig config = new AppConfig(getActivity()); - config.resetEventConfig(); - Toast.makeText(getActivity(), R.string.reset_success, Toast.LENGTH_SHORT).show(); + long cnt = ((PretixDroid) getActivity().getApplication()).getData().count(QueuedCheckIn.class).get().value(); + if (cnt > 0) { + new AlertDialog.Builder(getActivity()) + .setMessage(R.string.pref_reset_warning) + .setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + // do nothing + } + }) + .setPositiveButton(getString(R.string.ok), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + resetApp(); + } + }).create().show(); + + } else { + resetApp(); + } return true; } }); - Preference about = findPreference("action_about"); + final Preference about = findPreference("action_about"); about.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { @Override public boolean onPreferenceClick(Preference preference) { @@ -50,6 +83,48 @@ public boolean onPreferenceClick(Preference preference) { return true; } }); + + final CheckBoxPreference async = (CheckBoxPreference) findPreference("async"); + async.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + final AppConfig config = new AppConfig(getActivity()); + if (newValue instanceof Boolean && ((Boolean) newValue) != config.getAsyncModeEnabled()) { + final boolean isEnabled = (Boolean) newValue; + if (isEnabled) { + if (config.getApiVersion() < 3) { + new AlertDialog.Builder(getActivity()) + .setMessage(R.string.pref_async_not_supported) + .setPositiveButton(getString(R.string.dismiss), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + } + }).create().show(); + return false; + } + + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.pref_async) + .setMessage(R.string.pref_async_warning) + .setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + } + }) + .setPositiveButton(getString(R.string.ok), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + config.setAsyncModeEnabled(true); + async.setChecked(true); + } + }).create().show(); + + return false; + } + } + return true; + } + }); } private void asset_dialog(@RawRes int htmlRes, @StringRes int title) { diff --git a/pretixdroid/app/src/main/res/layout/activity_main.xml b/pretixdroid/app/src/main/res/layout/activity_main.xml index ad4bb8c..9a07ed5 100644 --- a/pretixdroid/app/src/main/res/layout/activity_main.xml +++ b/pretixdroid/app/src/main/res/layout/activity_main.xml @@ -68,6 +68,32 @@ android:id="@+id/qrdecoderview" android:layout_width="match_parent" android:layout_height="fill_parent" - android:layout_below="@id/rlScanStatus" /> + android:layout_below="@id/rlScanStatus"> + + + + + + + \ No newline at end of file diff --git a/pretixdroid/app/src/main/res/values-de/strings.xml b/pretixdroid/app/src/main/res/values-de/strings.xml index f0abdda..dbc644d 100644 --- a/pretixdroid/app/src/main/res/values-de/strings.xml +++ b/pretixdroid/app/src/main/res/values-de/strings.xml @@ -39,4 +39,29 @@ Sonstiges Scannen Sie können nun einen neuen Konfigurations-Code scannen + Asynchrones Scannen (beta) + Gescannte Tickets werden mit einer Offline-Kopie der Datenbank abgeglichen. Die App versucht sich regelmäßig automatisch mit dem Server zu synchronisieren. + Gescannte Tickets werden direkt mit dem Server abgeglichen. Die App benötigt eine Internetverbindung. + Asynchrones Scannen wird von Ihrem pretix-Server nicht unterstützt. Bitte prüfen Sie pretix auf Updates. + Wenn Sie asynchrones Scannen anschalten, wird dieses Gerät nicht immer den aktuellesten Stand der Datenbank kennen. Das kann, beispielsweise wenn Sie mehrere Geräte einsetzen, dazu führen, dass das selbe Ticket auf zwei verschiedenen Geräten zweimal als gültig angezeigt wird, wenn in kurzer Folge mehrfach gescannt wird. + Die App ist nicht voll synchronisiert. Wenn Sie sie nun zurücksetzen, könnten einige auf diesem Gerät geschehene Checkins dauerhaft verloren gehen. + + %d Minute + %d Minuten + + + %d Stunde + %d Stunden + + + %d Tag + %d Tage + + Synchronisierungs-Status + Letzte erfolgreiche Synchronisierung: + Letzte fehlgeschlagene Synchronisierung: + nicht synchronisiert + gerade eben + dd.MM.yyyy HH:mm:ss + Noch nicht hochgeladene Checkins: \ No newline at end of file diff --git a/pretixdroid/app/src/main/res/values/strings.xml b/pretixdroid/app/src/main/res/values/strings.xml index 29b42e4..7e934f7 100644 --- a/pretixdroid/app/src/main/res/values/strings.xml +++ b/pretixdroid/app/src/main/res/values/strings.xml @@ -2,10 +2,11 @@ Play sounds pretixdroid The scanned QR code is not valid. - Your app is not compatible with the version of pretix on the server. Please check for updates. + Your app is older than the version of pretix on the server. Please check for updates of the pretixdroid app. Configuration processed. You can now start scanning tickets. Error Dismiss + OK Unknown error pretixdroid Cancel @@ -38,4 +39,29 @@ Use device camera Disable if your device has a dedicated barcode scanner built-in. Only works for a few devices so far. Scanning + Asynchronous scanning (beta) + Scanned tickets are checked against an offline copy of the database. The app will try to synchronize with the server in regular intervals. + Scanned tickets are checked directly with the server. The app requires an internet connection to work. + Asynchronous scanning is not supported by the version of pretix running on your server. Please check for updates of pretix. + If you turn on asynchronous scanning, your phone will not be always up to date about the current database state. For example, if you use more than one device for scanning, a ticket might be shown as valid twice if it has already been scanned by a different device before. + Your app is not fully synchronized. If you reset it now, some scans made on this device might get permanently lost. + + %d minute + %d minutes + + + %d hour + %d hours + + + %d day + %d days + + Synchronization status + Last successful synchronization: + Last failed synchronization: + not yet synchronized + just now + yyyy-MM-dd HH:mm:ss + Checkins queued to upload: diff --git a/pretixdroid/app/src/main/res/xml/preferences.xml b/pretixdroid/app/src/main/res/xml/preferences.xml index 415c35e..eb38489 100644 --- a/pretixdroid/app/src/main/res/xml/preferences.xml +++ b/pretixdroid/app/src/main/res/xml/preferences.xml @@ -6,6 +6,12 @@ android:key="action_reset" android:summary="@string/hint_clear_config" android:title="@string/action_clear_config" /> +