package yuku.alkitab.base.sync;
import android.accounts.Account;
import android.content.ContentResolver;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.util.ArrayMap;
import android.util.Log;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import okhttp3.Call;
import okhttp3.FormBody;
import okhttp3.Request;
import okhttp3.RequestBody;
import yuku.afw.storage.Preferences;
import yuku.alkitab.base.App;
import yuku.alkitab.base.U;
import yuku.alkitab.base.model.SyncShadow;
import yuku.alkitab.base.storage.Prefkey;
import yuku.alkitab.base.util.Background;
import yuku.alkitab.debug.BuildConfig;
import yuku.alkitab.debug.R;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Sync {
static final String TAG = Sync.class.getSimpleName();
public enum Opkind {
add, mod, del, // do not change the enum value names here. This will be un/serialized by gson.
}
public enum ApplyAppendDeltaResult {
ok,
unknown_kind,
/** Entities have changed during sync request */
dirty_entities,
/** Sync user account has changed during sync request */
dirty_sync_account,
/** Unsupported operation encountered */
unsupported_operation,
}
public static class Operation<C> {
public Opkind opkind;
public String kind;
public String gid;
public C content;
public String creator_id; // for now, only used when receiving from server, not sending
public Operation(final Opkind opkind, final String kind, final String gid, final C content) {
this.opkind = opkind;
this.kind = kind;
this.gid = gid;
this.content = content;
}
@Override
public String toString() {
return "{" + opkind +
" " + kind +
" " + (gid.length() <= 10? gid: gid.substring(0, 10)) +
" " + content +
'}';
}
}
public static class Delta<C> {
@NonNull public List<Operation<C>> operations;
public Delta() {
operations = new ArrayList<>();
}
@Override
public String toString() {
return "Delta{" +
"operations=" + operations +
'}';
}
}
public static class Entity<C> {
public static final String KIND_MARKER = "Marker";
public static final String KIND_LABEL = "Label";
public static final String KIND_MARKER_LABEL = "Marker_Label";
public static final String KIND_HISTORY_ENTRY = "HistoryEntry";
public static final String KIND_PINS = "Pins"; // with plural to indicate not only 1 pin but all pins considered as one entity
public static final String KIND_RP_PROGRESS = "RpProgress";
/**
* Kind of this entity. One of the <code>KIND_</code> constants on {@link yuku.alkitab.base.sync.Sync.Entity}.
*/
public final String kind;
public final String gid;
public final C content;
public Entity(final String kind, final String gid, final C content) {
this.kind = kind;
this.gid = gid;
this.content = content;
}
//region Boilerplate equals and hashCode
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Entity entity = (Entity) o;
if (content != null ? !content.equals(entity.content) : entity.content != null) return false;
if (gid != null ? !gid.equals(entity.gid) : entity.gid != null) return false;
if (kind != null ? !kind.equals(entity.kind) : entity.kind != null) return false;
return true;
}
@Override
public int hashCode() {
int result = kind != null ? kind.hashCode() : 0;
result = 31 * result + (gid != null ? gid.hashCode() : 0);
result = 31 * result + (content != null ? content.hashCode() : 0);
return result;
}
//endregion
}
public static class ClientState<C> {
public final int base_revno;
@NonNull public final Delta<C> delta;
public ClientState(final int base_revno, @NonNull final Delta<C> delta) {
this.base_revno = base_revno;
this.delta = delta;
}
}
public static class SyncShadowDataJson<C> {
public List<Entity<C>> entities;
}
public static class SyncResponseJson<C> extends ResponseJson {
public int final_revno;
public Delta<C> append_delta;
}
public static class GetClientStateResult<C> {
public final ClientState<C> clientState;
public final List<Entity<C>> shadowEntities;
public final List<Entity<C>> currentEntities;
public GetClientStateResult(final ClientState<C> clientState, final List<Entity<C>> shadowEntities, final List<Entity<C>> currentEntities) {
this.clientState = clientState;
this.shadowEntities = shadowEntities;
this.currentEntities = currentEntities;
}
}
/**
* Ignoring order, check if all the entities are the same.
*/
public static <C> boolean entitiesEqual(@NonNull final List<Entity<C>> a_, @NonNull final List<Entity<C>> b_) {
if (a_.size() != b_.size()) return false;
final ArrayList<Entity<C>> a = new ArrayList<>(a_);
final ArrayList<Entity<C>> b = new ArrayList<>(b_);
final Comparator<Entity<C>> cmp = (lhs, rhs) -> lhs.gid.compareTo(rhs.gid);
Collections.sort(a, cmp);
Collections.sort(b, cmp);
return a.equals(b);
}
private static final ArrayMap<String, AtomicInteger> syncUpdatesOngoingCounters = new ArrayMap<>();
private static final ScheduledExecutorService syncExecutor = Executors.newSingleThreadScheduledExecutor();
private static final ConcurrentLinkedQueue<String> syncSetNameQueue = new ConcurrentLinkedQueue<>();
/**
* Notify that we need to sync with server.
* @param syncSetNames The names of the sync set that needs sync with server. Should be list of {@link yuku.alkitab.base.model.SyncShadow#SYNC_SET_MABEL} or others.
*/
public static synchronized void notifySyncNeeded(final String... syncSetNames) {
// if not logged in, do nothing
if (Preferences.getString(Prefkey.sync_simpleToken) == null) {
return;
}
for (final String syncSetName : syncSetNames) {
final AtomicInteger counter = syncUpdatesOngoingCounters.get(syncSetName);
if (counter != null && counter.get() != 0) {
Log.d(TAG, "@@notifySyncNeeded " + syncSetName + " ignored: ongoing counter != 0");
return;
}
{ // check if preferences prevent syncing
if (!Preferences.getBoolean(prefkeyForSyncSetEnabled(syncSetName), true)) {
continue;
}
}
SyncRecorder.log(SyncRecorder.EventKind.sync_needed_notified, syncSetName);
// check if we can omit queueing sync request for this sync set name.
synchronized (syncSetNameQueue) {
if (syncSetNameQueue.contains(syncSetName)) {
Log.d(TAG, "@@notifySyncNeeded " + syncSetName + " ignored: sync queue already contains it");
continue;
}
syncSetNameQueue.add(syncSetName);
}
}
syncExecutor.schedule(() -> {
while (true) {
final List<String> extraSyncSetNames = new ArrayList<>();
synchronized (syncSetNameQueue) {
final String extraSyncSetName = syncSetNameQueue.poll();
if (extraSyncSetName == null) {
break;
}
extraSyncSetNames.add(extraSyncSetName);
}
if (extraSyncSetNames.size() == 0) {
return;
}
final Account account = SyncUtils.getOrCreateSyncAccount();
final String authority = App.context.getString(R.string.sync_provider_authority);
// make sure sync is enabled.
final boolean syncAutomatically = ContentResolver.getSyncAutomatically(account, authority);
if (!syncAutomatically) {
ContentResolver.setSyncAutomatically(account, authority, true);
}
// request sync.
final Bundle extras = new Bundle();
extras.putString(SyncAdapter.EXTRA_SYNC_SET_NAMES, App.getDefaultGson().toJson(extraSyncSetNames));
ContentResolver.requestSync(account, authority, extras);
}
}, 5, TimeUnit.SECONDS);
}
/**
* Call this method(true) when updating local storage because of sync. Call this method(false) when finished.
* Calls to {@link #notifySyncNeeded(String...)} will be a no-op when sync updates are ongoing (marked by this method being called).
* @param isRunning true to start, false to stop.
*/
public static synchronized void notifySyncUpdatesOngoing(final String syncSetName, final boolean isRunning) {
AtomicInteger counter = syncUpdatesOngoingCounters.get(syncSetName);
if (counter == null) {
counter = new AtomicInteger(0);
syncUpdatesOngoingCounters.put(syncSetName, counter);
}
if (isRunning) {
counter.incrementAndGet();
} else {
counter.decrementAndGet();
}
}
/**
* Returns the effective server prefix for syncing.
* @return scheme, host, port, with the trailing slash.
*/
public static String getEffectiveServerPrefix() {
final String override = Preferences.getString(Prefkey.sync_server_prefix);
if (override != null) {
return override;
}
if (BuildConfig.DEBUG) {
return "http://10.0.3.2:9080/";
} else {
return BuildConfig.SERVER_HOST;
}
}
static class RegisterGcmClientResponseJson extends ResponseJson {
public boolean is_new_registration_id;
}
public static void notifyNewGcmRegistrationId(final String newRegistrationId) {
// must send to server if we are logged in
final String simpleToken = Preferences.getString(Prefkey.sync_simpleToken);
if (simpleToken == null) {
Log.d(TAG, "Got new GCM registration id, but sync is not logged in");
return;
}
Background.run(() -> sendGcmRegistrationId(simpleToken, newRegistrationId));
}
public static boolean sendGcmRegistrationId(final String simpleToken, final String registration_id) {
final RequestBody requestBody = new FormBody.Builder()
.add("simpleToken", simpleToken)
.add("sender_id", Gcm.SENDER_ID) // not really needed, but for logging on server
.add("registration_id", registration_id)
.build();
try {
final Call call = App.getLongTimeoutOkHttpClient().newCall(
new Request.Builder()
.url(getEffectiveServerPrefix() + "sync/api/register_gcm_client")
.post(requestBody)
.build()
);
SyncRecorder.log(SyncRecorder.EventKind.gcm_send_attempt, null);
final RegisterGcmClientResponseJson response = App.getDefaultGson().fromJson(call.execute().body().charStream(), RegisterGcmClientResponseJson.class);
if (!response.success) {
SyncRecorder.log(SyncRecorder.EventKind.gcm_send_not_success, null, "message", response.message);
Log.d(TAG, "GCM registration id rejected by server: " + response.message);
return false;
}
SyncRecorder.log(SyncRecorder.EventKind.gcm_send_success, null, "is_new_registration_id", response.is_new_registration_id);
Log.d(TAG, "GCM registration id accepted by server: is_new_registration_id=" + response.is_new_registration_id);
return true;
} catch (IOException | JsonIOException e) {
SyncRecorder.log(SyncRecorder.EventKind.gcm_send_error_io, null);
Log.d(TAG, "Failed to send GCM registration id to server", e);
return false;
} catch (JsonSyntaxException e) {
SyncRecorder.log(SyncRecorder.EventKind.gcm_send_error_json, null);
Log.d(TAG, "Server response is not valid JSON", e);
return false;
}
}
public static class ResponseJson {
public boolean success;
public String message;
}
public static class RegisterForm {
public String email;
public String password;
public String church;
public String city;
public String religion;
}
/**
* Exception thrown by calls to server that has io/parse exception or when server returns success==false.
*/
static class NotOkException extends Exception {
public NotOkException(final String msg) {
super(msg);
}
}
/**
* Create an own user account.
* Must be called from a background thread.
*/
@NonNull public static LoginResponseJson register(@NonNull final RegisterForm form) throws NotOkException {
final FormBody.Builder b = new FormBody.Builder();
if (form.church != null) b.add("church", form.church);
if (form.city != null) b.add("city", form.city);
if (form.religion != null) b.add("religion", form.religion);
final RequestBody requestBody = b
.add("email", form.email)
.add("password", form.password)
.add("installation_info", U.getInstallationInfoJson())
.build();
try {
final Call call = App.getLongTimeoutOkHttpClient().newCall(
new Request.Builder()
.url(getEffectiveServerPrefix() + "sync/api/create_own_user")
.post(requestBody)
.build()
);
final LoginResponseJson response = App.getDefaultGson().fromJson(call.execute().body().charStream(), LoginResponseJson.class);
if (!response.success) {
throw new NotOkException(response.message);
}
return response;
} catch (IOException | JsonIOException e) {
throw new NotOkException("Failed to send data");
} catch (JsonSyntaxException e) {
throw new NotOkException("Server response is not a valid JSON");
}
}
/**
* Log in to own user account using email and password.
* Must be called from a background thread.
*/
@NonNull public static LoginResponseJson login(@NonNull final String email, @NonNull final String password) throws NotOkException {
final RequestBody requestBody = new FormBody.Builder()
.add("email", email)
.add("password", password)
.add("installation_info", U.getInstallationInfoJson())
.build();
try {
final Call call = App.getLongTimeoutOkHttpClient().newCall(
new Request.Builder()
.url(getEffectiveServerPrefix() + "sync/api/login_own_user")
.post(requestBody)
.build()
);
final LoginResponseJson response = App.getDefaultGson().fromJson(call.execute().body().charStream(), LoginResponseJson.class);
if (!response.success) {
throw new NotOkException(response.message);
}
return response;
} catch (IOException | JsonIOException e) {
throw new NotOkException("Failed to send data");
} catch (JsonSyntaxException e) {
throw new NotOkException("Server response is not a valid JSON");
}
}
/**
* Ask the server to allow user to reset password.
* Must be called from a background thread.
*/
public static void forgotPassword(@NonNull final String email) throws NotOkException {
final RequestBody requestBody = new FormBody.Builder()
.add("email", email)
.build();
try {
final Call call = App.getLongTimeoutOkHttpClient().newCall(
new Request.Builder()
.url(getEffectiveServerPrefix() + "sync/api/forgot_password")
.post(requestBody)
.build()
);
final ResponseJson response = App.getDefaultGson().fromJson(call.execute().body().charStream(), ResponseJson.class);
if (!response.success) {
throw new NotOkException(response.message);
}
} catch (IOException | JsonIOException e) {
throw new NotOkException("Failed to send data");
} catch (JsonSyntaxException e) {
throw new NotOkException("Server response is not a valid JSON");
}
}
/**
* Ask the server to change password.
* Must be called from a background thread.
*/
public static void changePassword(@NonNull final String email, @NonNull final String password_old, @NonNull final String password_new) throws NotOkException {
final RequestBody requestBody = new FormBody.Builder()
.add("email", email)
.add("password_old", password_old)
.add("password_new", password_new)
.build();
try {
final Call call = App.getLongTimeoutOkHttpClient().newCall(
new Request.Builder()
.url(getEffectiveServerPrefix() + "sync/api/change_password")
.post(requestBody)
.build()
);
final ResponseJson response = App.getDefaultGson().fromJson(call.execute().body().charStream(), ResponseJson.class);
if (!response.success) {
throw new NotOkException(response.message);
}
} catch (IOException | JsonIOException e) {
throw new NotOkException("Failed to send data");
} catch (JsonSyntaxException e) {
throw new NotOkException("Server response is not a valid JSON");
}
}
public static class LoginResponseJson extends ResponseJson {
public String simpleToken;
}
public static String prefkeyForSyncSetEnabled(final String syncSetName) {
return "syncSet_" + syncSetName + "_enabled";
}
public static void forceSyncNow() {
final Account account = SyncUtils.getOrCreateSyncAccount();
final String authority = App.context.getString(R.string.sync_provider_authority);
SyncRecorder.log(SyncRecorder.EventKind.sync_forced, null);
// make sure sync is enabled.
final boolean syncAutomatically = ContentResolver.getSyncAutomatically(account, authority);
if (!syncAutomatically) {
ContentResolver.setSyncAutomatically(account, authority, true);
}
// request sync.
final List<String> syncSetNames = new ArrayList<>();
Collections.addAll(syncSetNames, SyncShadow.ALL_SYNC_SET_NAMES);
final Bundle extras = new Bundle();
extras.putString(SyncAdapter.EXTRA_SYNC_SET_NAMES, App.getDefaultGson().toJson(syncSetNames));
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
ContentResolver.requestSync(account, authority, extras);
}
}