package com.zulip.android; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.content.pm.PackageInfo; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Handler; import android.util.Log; import com.crashlytics.android.Crashlytics; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.TypeAdapter; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.dao.ObjectCache; import com.j256.ormlite.dao.ReferenceObjectCache; import com.j256.ormlite.dao.RuntimeExceptionDao; import com.j256.ormlite.misc.TransactionManager; import com.zulip.android.activities.ZulipActivity; import com.zulip.android.database.DatabaseHelper; import com.zulip.android.models.Emoji; import com.zulip.android.models.Message; import com.zulip.android.models.MessageType; import com.zulip.android.models.Person; import com.zulip.android.models.Presence; import com.zulip.android.models.Stream; import com.zulip.android.networking.AsyncUnreadMessagesUpdate; import com.zulip.android.networking.ZulipInterceptor; import com.zulip.android.networking.response.UserConfigurationResponse; import com.zulip.android.networking.response.events.EventsBranch; import com.zulip.android.networking.response.events.SubscriptionWrapper; import com.zulip.android.service.ZulipServices; import com.zulip.android.util.Constants; import com.zulip.android.util.GoogleAuthHelper; import com.zulip.android.util.ZLog; import java.io.IOException; import java.lang.reflect.Type; import java.sql.SQLException; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import io.fabric.sdk.android.Fabric; import okhttp3.OkHttpClient; import okhttp3.ResponseBody; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; /** * Stores the global variables which are frequently used. * <p> * {@link #max_message_id} This is the last Message ID stored in our database. * {@link #you} A reference to the user currently logged in. * {@link #api_key} Stores the API_KEY which was obtained from the server on successful authentication. * {@link #setupEmoji()} This is called to initialize and add records the existing emoticons in the assets folder. */ public class ZulipApp extends Application { private static final String API_KEY = "api_key"; private static final String EMAIL = "email"; private static final String USER_AGENT = "ZulipAndroid"; private static final String DEFAULT_SERVER_URL = "https://api.zulip.com/"; private static ZulipApp instance; /** * Mapping of email address to presence information for that user. This is * updated every 2 minutes by a background thread (see AsyncStatusUpdate) */ public final Map<String, Presence> presences = new ConcurrentHashMap<>(); /** * Queue of message ids to be marked as read. This queue should be emptied * every couple of seconds */ public final Queue<Integer> unreadMessageQueue = new ConcurrentLinkedQueue<>(); // This object's intrinsic lock is used to prevent multiple threads from // making conflicting updates to ranges public Object updateRangeLock = new Object(); public String tester; private Person you; private SharedPreferences settings; private String api_key; private int max_message_id; private DatabaseHelper databaseHelper; private ZulipServices zulipServices; private ZulipServices uploadServices; private ReferenceObjectCache objectCache; private ZulipActivity zulipActivity; /** * Handler to manage batching of unread messages */ private Handler unreadMessageHandler; private String eventQueueId; private int lastEventId; private int pointer; private Gson gson; public static ZulipApp get() { return instance; } private static void setInstance(ZulipApp instance) { ZulipApp.instance = instance; } public ZulipActivity getZulipActivity() { return zulipActivity; } public void setZulipActivity(ZulipActivity zulipActivity) { this.zulipActivity = zulipActivity; } @Override public void onCreate() { super.onCreate(); if (!BuildConfig.DEBUG) Fabric.with(this, new Crashlytics()); ZulipApp.setInstance(this); // This used to be from HumbugActivity.getPreferences, so we keep that // file name. this.settings = getSharedPreferences("HumbugActivity", Context.MODE_PRIVATE); max_message_id = settings.getInt("max_message_id", -1); eventQueueId = settings.getString("eventQueueId", null); lastEventId = settings.getInt("lastEventId", -1); pointer = settings.getInt("pointer", -1); this.api_key = settings.getString(API_KEY, null); if (api_key != null) { afterLogin(); } // create unread message queue unreadMessageHandler = new Handler(new Handler.Callback() { @Override public boolean handleMessage(android.os.Message message) { if (message.what == 0) { AsyncUnreadMessagesUpdate task = new AsyncUnreadMessagesUpdate(ZulipApp.this); task.execute(); } // documentation doesn't say what this value does for // Handler.Callback, // and Handler.handleMessage returns void // so this just returns true. return true; } }); } public int getAppVersion() { try { PackageInfo packageInfo = this.getPackageManager().getPackageInfo( this.getPackageName(), 0); return packageInfo.versionCode; } catch (NameNotFoundException e) { throw new RuntimeException("Could not get package version: " + e); } } public ObjectCache getObjectCache() { if (objectCache == null) { objectCache = new ReferenceObjectCache(true); } return objectCache; } private void afterLogin() { String email = settings.getString(EMAIL, null); setEmail(email); setupEmoji(); } public ZulipServices getZulipServices() { if (zulipServices == null) { HttpLoggingInterceptor logging = new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY); zulipServices = new Retrofit.Builder() .client(new OkHttpClient.Builder().readTimeout(60, TimeUnit.SECONDS) .addInterceptor(new ZulipInterceptor()) .addInterceptor(logging) .build()) .addConverterFactory(GsonConverterFactory.create(getGson())) .baseUrl(getServerURI()) .build() .create(ZulipServices.class); } return zulipServices; } public void setZulipServices(ZulipServices zulipServices) { this.zulipServices = zulipServices; } public ZulipServices getUploadServices() { if (uploadServices == null) { HttpLoggingInterceptor logging = new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.HEADERS); uploadServices = new Retrofit.Builder() .client(new OkHttpClient.Builder().readTimeout(60, TimeUnit.SECONDS) .addInterceptor(new ZulipInterceptor()) .addInterceptor(logging) .build()) .addConverterFactory(GsonConverterFactory.create(getGson())) .baseUrl(getServerURI()) .build() .create(ZulipServices.class); } return uploadServices; } public Gson getGson() { if (gson == null) { gson = buildGson(); } return gson; } private Gson buildGson() { final Gson naiveGson = new GsonBuilder() .registerTypeAdapter(Date.class, new JsonDeserializer<Date>() { public Date deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { return new Date(json.getAsJsonPrimitive().getAsLong() * 1000); } }) .registerTypeAdapter(MessageType.class, new JsonDeserializer<MessageType>() { @Override public MessageType deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { return json.getAsString().equalsIgnoreCase("stream") ? MessageType.STREAM_MESSAGE : MessageType.PRIVATE_MESSAGE; } }) .create(); final Gson nestedGson = new GsonBuilder() .registerTypeAdapter(Message.class, new JsonDeserializer<Message>() { @Override public Message deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (BuildConfig.DEBUG) { Log.d("RAW MESSAGES", json.toString()); } Message genMess; if ("stream".equalsIgnoreCase(json.getAsJsonObject().get("type").getAsString())) { Message.ZulipStreamMessage msg = naiveGson.fromJson(json, Message.ZulipStreamMessage.class); msg.setRecipients(msg.getDisplayRecipient()); genMess = msg; } else { Message.ZulipDirectMessage msg = naiveGson.fromJson(json, Message.ZulipDirectMessage.class); if (msg.getDisplayRecipient() != null) { // set the correct person id for message recipients List<Person> people = msg.getDisplayRecipient(); for (Person person : people) { // this is needed as the json field name for person id is // "user_id" in realm configuration response and "id" in // GET v1/messages response. person.setId(person.getRecipientId()); } msg.setRecipients(people.toArray(new Person[people.size()])); } msg.setContent(Message.formatContent(msg.getFormattedContent(), ZulipApp.get()).toString()); genMess = msg; } if (genMess._history != null && genMess._history.size() != 0) { genMess.updateFromHistory(genMess._history.get(0)); } return genMess; } }).create(); return new GsonBuilder() .registerTypeAdapter(UserConfigurationResponse.class, new TypeAdapter<UserConfigurationResponse>() { @Override public void write(JsonWriter out, UserConfigurationResponse value) throws IOException { nestedGson.toJson(nestedGson.toJsonTree(value), out); } @Override public UserConfigurationResponse read(JsonReader in) throws IOException { return nestedGson.fromJson(in, UserConfigurationResponse.class); } }) .registerTypeAdapter(EventsBranch.class, new JsonDeserializer<EventsBranch>() { @Override public EventsBranch deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { EventsBranch invalid = nestedGson.fromJson(json, EventsBranch.class); if (BuildConfig.DEBUG) { Log.d("RAW EVENTS", json.toString()); } Class<? extends EventsBranch> event = EventsBranch.BranchType.fromRawType(invalid); if (event != null) { if (event.getSimpleName().equalsIgnoreCase("SubscriptionWrapper")) { // check operation if (SubscriptionWrapper.OPERATION_ADD.equalsIgnoreCase(json.getAsJsonObject().get("op").getAsString()) || SubscriptionWrapper.OPERATION_REMOVE.equalsIgnoreCase(json.getAsJsonObject().get("op").getAsString()) || SubscriptionWrapper.OPERATION_UPDATE.equalsIgnoreCase(json.getAsJsonObject().get("op").getAsString())) { Type type = new TypeToken<SubscriptionWrapper<Stream>>() { }.getType(); return nestedGson.fromJson(json, type); } else { Type type = new TypeToken<SubscriptionWrapper<String>>() { }.getType(); return nestedGson.fromJson(json, type); } } return nestedGson.fromJson(json, event); } Log.w("GSON", "Attempted to deserialize and unregistered EventBranch... See EventBranch.BranchType"); return invalid; } }) .registerTypeAdapter(Message.class, nestedGson.getAdapter(Message.class)) .create(); } /** * Fills the Emoji Table with the existing emoticons saved in the assets folder. */ private void setupEmoji() { try { final RuntimeExceptionDao<Emoji, Object> dao = getDao(Emoji.class); if (dao.queryForAll().size() != 0) return; final String emojis[] = getAssets().list("emoji"); TransactionManager.callInTransaction(getDatabaseHelper() .getConnectionSource(), new Callable<Void>() { public Void call() throws Exception { for (String newEmoji : emojis) { //currently emojis are in png format newEmoji = newEmoji.replace(".png", ""); dao.create(new Emoji(newEmoji)); } return null; } }); } catch (SQLException | IOException e) { ZLog.logException(e); } } public Boolean isLoggedIn() { return this.api_key != null; } public void clearConnectionState() { setEventQueueId(null); } /** * Determines the server URI applicable for the user. * * @return either the production or staging server's URI */ public String getServerURI() { return settings.getString("server_url", DEFAULT_SERVER_URL); } public String getServerHostUri() { String uri = settings.getString("server_url", DEFAULT_SERVER_URL); if (uri.contains("/api/")) { uri = uri.replace("/api/", "/"); } else if (uri.contains("/api.")) { uri = uri.replace("/api.", "/"); } return uri; } public String getApiKey() { return api_key; } public String getUserAgent() { try { return ZulipApp.USER_AGENT + "/" + getPackageManager().getPackageInfo(getPackageName(), 0).versionName; } catch (NameNotFoundException e) { // This should… never happen, but okay. ZLog.logException(e); return ZulipApp.USER_AGENT + "/unknown"; } } public void setServerURL(String serverURL) { Editor ed = this.settings.edit(); ed.putString("server_url", serverURL); ed.apply(); } public void useDefaultServerURL() { setServerURL(DEFAULT_SERVER_URL); } public void setLoggedInApiKey(String apiKey, String email) { this.api_key = apiKey; Editor ed = this.settings.edit(); ed.putString(EMAIL, email); ed.putString(API_KEY, apiKey); ed.apply(); afterLogin(); } public void logOut() { Editor ed = this.settings.edit(); ed.remove(EMAIL); ed.remove(API_KEY); ed.remove("server_url"); ed.apply(); this.api_key = null; setEventQueueId(null); new GoogleAuthHelper().logOutGoogleAuth(); } public String getEmail() { return you == null ? "" : you.getEmail(); } public void setEmail(String email) { databaseHelper = new DatabaseHelper(this, email); this.you = Person.get(this, email); } public DatabaseHelper getDatabaseHelper() { return databaseHelper; } @SuppressWarnings("unchecked") public <C, T> RuntimeExceptionDao<C, T> getDao(Class<C> cls, boolean useCache) { try { RuntimeExceptionDao<C, T> ret = new RuntimeExceptionDao<>( (Dao<C, T>) databaseHelper.getDao(cls)); if (useCache) { ret.setObjectCache(getObjectCache()); } return ret; } catch (SQLException e) { // Well that's sort of awkward. throw new RuntimeException(e); } } public <C, T> RuntimeExceptionDao<C, T> getDao(Class<C> cls) { return getDao(cls, false); } public void setContext(Context targetContext) { this.attachBaseContext(targetContext); } public String getEventQueueId() { return eventQueueId; } public void setEventQueueId(String eventQueueId) { this.eventQueueId = eventQueueId; Editor ed = settings.edit(); ed.putString("eventQueueId", this.eventQueueId); ed.apply(); } public int getLastEventId() { return lastEventId; } public void setLastEventId(int lastEventId) { this.lastEventId = lastEventId; Editor ed = settings.edit(); ed.putInt("lastEventId", lastEventId); ed.apply(); } public int getMaxMessageId() { return max_message_id; } public void setMaxMessageId(int maxMessageId) { this.max_message_id = maxMessageId; if (settings != null) { Editor ed = settings.edit(); ed.putInt("max_message_id", maxMessageId); ed.apply(); } } public int getPointer() { return pointer; } public void setPointer(int pointer) { this.pointer = pointer; Editor ed = settings.edit(); ed.putInt("pointer", pointer); ed.apply(); } public void resetDatabase() { databaseHelper.resetDatabase(databaseHelper.getWritableDatabase()); } public void onResetDatabase() { setPointer(-1); setMaxMessageId(-1); setLastEventId(-1); setEventQueueId(null); } public void markMessageAsRead(Message message) { if (unreadMessageHandler == null) { Log.e("zulipApp", "markMessageAsRead called before unreadMessageHandler was instantiated"); return; } unreadMessageQueue.offer(message.getID()); if (!unreadMessageHandler.hasMessages(0)) { unreadMessageHandler.sendEmptyMessageDelayed(0, 2000); } } public SharedPreferences getSettings() { return settings; } public Person getYou() { return you; } public void setYou(Person person) { this.you = person; } public void syncPointer(final int mID) { getZulipServices().updatePointer(Integer.toString(mID)) .enqueue(new Callback<ResponseBody>() { @Override public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) { setPointer(mID); } @Override public void onFailure(Call<ResponseBody> call, Throwable t) { //do nothing.. don't want to mis-update the pointer. } }); } /** * Sets message content editing parameters * * @param seconds time limit in seconds for editing message * @param param parameter indicating editing message is allowed or not */ public void setMessageContentEditParams(int seconds, boolean param) { SharedPreferences preferences = getSettings(); SharedPreferences.Editor editor = preferences.edit(); //Firsts Checks if maximum content edit limit is already saved or not if (!preferences.getBoolean(Constants.IS_CONTENT_EDIT_PARAM_SAVED, false)) { editor.putBoolean(Constants.IS_EDITING_ALLOWED, param); editor.putInt(Constants.MAXIMUM_CONTENT_EDIT_LIMIT, seconds); editor.putBoolean(Constants.IS_CONTENT_EDIT_PARAM_SAVED, true); editor.apply(); } else { //Check if any value is changed from server if (getSettings().getInt(Constants.MAXIMUM_CONTENT_EDIT_LIMIT, Constants.DEFAULT_MAXIMUM_CONTENT_EDIT_LIMIT) != seconds) { //time is changed from server and update it locally too editor.putInt(Constants.MAXIMUM_CONTENT_EDIT_LIMIT, seconds); editor.apply(); } if (getSettings().getBoolean(Constants.IS_EDITING_ALLOWED, Constants.DEFAULT_EDITING_ALLOWED) != param) { //isEditingAllowed is changed from server and update it locally too editor.putBoolean(Constants.IS_EDITING_ALLOWED, param); editor.apply(); } } } }