package org.wikipedia;
import android.app.Activity;
import android.app.Application;
import android.os.Build;
import android.os.Handler;
import android.support.annotation.IntRange;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatDelegate;
import android.view.Window;
import android.webkit.WebView;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imagepipeline.core.ImagePipelineConfig;
import com.squareup.leakcanary.LeakCanary;
import com.squareup.leakcanary.RefWatcher;
import com.squareup.otto.Bus;
import org.mediawiki.api.json.Api;
import org.wikipedia.analytics.FunnelManager;
import org.wikipedia.analytics.SessionFunnel;
import org.wikipedia.auth.AccountUtil;
import org.wikipedia.crash.CrashReporter;
import org.wikipedia.crash.hockeyapp.HockeyAppCrashReporter;
import org.wikipedia.database.Database;
import org.wikipedia.database.DatabaseClient;
import org.wikipedia.dataclient.SharedPreferenceCookieManager;
import org.wikipedia.dataclient.WikiSite;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.dataclient.okhttp.CacheableOkHttpNetworkFetcher;
import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory;
import org.wikipedia.edit.summaries.EditSummary;
import org.wikipedia.events.ChangeTextSizeEvent;
import org.wikipedia.events.ThemeChangeEvent;
import org.wikipedia.history.HistoryEntry;
import org.wikipedia.language.AcceptLanguageUtil;
import org.wikipedia.language.AppLanguageLookUpTable;
import org.wikipedia.language.AppLanguageState;
import org.wikipedia.login.User;
import org.wikipedia.login.UserIdClient;
import org.wikipedia.notifications.NotificationPollBroadcastReceiver;
import org.wikipedia.onboarding.OnboardingStateMachine;
import org.wikipedia.onboarding.PrefsOnboardingStateMachine;
import org.wikipedia.pageimages.PageImage;
import org.wikipedia.readinglist.database.ReadingListRow;
import org.wikipedia.readinglist.page.ReadingListPageRow;
import org.wikipedia.readinglist.page.database.ReadingListPageHttpRow;
import org.wikipedia.readinglist.page.database.disk.ReadingListPageDiskRow;
import org.wikipedia.savedpages.SavedPage;
import org.wikipedia.search.RecentSearch;
import org.wikipedia.settings.Prefs;
import org.wikipedia.settings.RemoteConfig;
import org.wikipedia.theme.Theme;
import org.wikipedia.useroption.UserOption;
import org.wikipedia.useroption.database.UserOptionDao;
import org.wikipedia.useroption.database.UserOptionRow;
import org.wikipedia.useroption.sync.UserOptionContentResolver;
import org.wikipedia.util.ReleaseUtil;
import org.wikipedia.util.log.L;
import org.wikipedia.views.ViewAnimations;
import org.wikipedia.zero.WikipediaZeroHandler;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import retrofit2.Call;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.wikipedia.util.DimenUtil.getFontSizeFromSp;
import static org.wikipedia.util.ReleaseUtil.getChannel;
public class WikipediaApp extends Application {
private static final int EVENT_LOG_TESTING_ID = new Random().nextInt(Integer.MAX_VALUE);
public static final int FONT_SIZE_MULTIPLIER_MIN = -5;
public static final int FONT_SIZE_MULTIPLIER_MAX = 8;
private static final float FONT_SIZE_FACTOR = 0.1f;
private final RemoteConfig remoteConfig = new RemoteConfig();
private final Map<Class<?>, DatabaseClient<?>> databaseClients = Collections.synchronizedMap(new HashMap<Class<?>, DatabaseClient<?>>());
private final Map<String, Api> apis = new HashMap<>();
private AppLanguageState appLanguageState;
private FunnelManager funnelManager;
private SessionFunnel sessionFunnel;
private NotificationPollBroadcastReceiver notificationReceiver = new NotificationPollBroadcastReceiver();
private Database database;
private String userAgent;
private WikiSite wiki;
@NonNull private UserIdClient idClient = new UserIdClient();
private CrashReporter crashReporter;
private RefWatcher refWatcher;
public SessionFunnel getSessionFunnel() {
return sessionFunnel;
}
/**
* Singleton instance of WikipediaApp
*/
private static WikipediaApp INSTANCE;
private Bus bus;
@NonNull private Theme currentTheme = Theme.getFallback();
private WikipediaZeroHandler zeroHandler;
public WikipediaZeroHandler getWikipediaZeroHandler() {
return zeroHandler;
}
public WikipediaApp() {
INSTANCE = this;
}
/**
* Returns the singleton instance of the WikipediaApp
*
* This is ok, since android treats it as a singleton anyway.
*/
public static WikipediaApp getInstance() {
return INSTANCE;
}
@Override
public void onCreate() {
super.onCreate();
zeroHandler = new WikipediaZeroHandler(this);
// HockeyApp exception handling interferes with the test runner, so enable it only for
// beta and stable releases
if (!ReleaseUtil.isPreBetaRelease()) {
initExceptionHandling();
}
refWatcher = Prefs.isMemoryLeakTestEnabled() ? LeakCanary.install(this) : RefWatcher.DISABLED;
// See Javadocs and http://developer.android.com/tools/support-library/index.html#rev23-4-0
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
bus = new Bus();
ViewAnimations.init(getResources());
currentTheme = unmarshalCurrentTheme();
initAppLang();
funnelManager = new FunnelManager(this);
sessionFunnel = new SessionFunnel(this);
database = new Database(this);
enableWebViewDebugging();
Api.setConnectionFactory(new OkHttpConnectionFactory());
ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
.setNetworkFetcher(new CacheableOkHttpNetworkFetcher(OkHttpConnectionFactory.getClient()))
.build();
Fresco.initialize(this, config);
// TODO: remove this code after all logged in users also have a system account or August 2016.
AccountUtil.createAccountForLoggedInUser();
UserOptionContentResolver.registerAppSyncObserver(this);
listenForNotifications();
}
public RefWatcher getRefWatcher() {
return refWatcher;
}
public Bus getBus() {
return bus;
}
public String getUserAgent() {
if (userAgent == null) {
String channel = getChannel(this);
channel = channel.equals("") ? channel : " ".concat(channel);
userAgent = String.format("WikipediaApp/%s (Android %s; %s)%s",
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
getString(R.string.device_type),
channel
);
}
return userAgent;
}
/**
* @return the value that should go in the Accept-Language header.
*/
@NonNull
public String getAcceptLanguage(@Nullable WikiSite wiki) {
String wikiLang = wiki == null || "meta".equals(wiki.languageCode())
? ""
: defaultString(wiki.languageCode());
return AcceptLanguageUtil.getAcceptLanguage(wikiLang, defaultString(getAppLanguageCode()),
appLanguageState.getSystemLanguageCode());
}
public Api getAPIForSite(WikiSite wiki) {
String host = wiki.host();
String acceptLanguage = getAcceptLanguage(wiki);
Map<String, String> customHeaders = buildCustomHeadersMap(acceptLanguage);
Api api;
String cachedApiKey = host + "-" + acceptLanguage;
if (apis.containsKey(cachedApiKey)) {
api = apis.get(cachedApiKey);
} else {
api = new Api(host, wiki.port(), wiki.secureScheme(),
wiki.path("api.php"), customHeaders);
apis.put(cachedApiKey, api);
}
return api;
}
/**
* Default wiki for the app
* You should use PageTitle.getWikiSite() to get the article wiki
*/
@NonNull public WikiSite getWikiSite() {
// TODO: why don't we ensure that the app language hasn't changed here instead of the client?
if (wiki == null) {
String lang = Prefs.getMediaWikiBaseUriSupportsLangCode() ? getAppOrSystemLanguageCode() : "";
wiki = WikiSite.forLanguageCode(lang);
}
return wiki;
}
/**
* Convenience method to get an API object for the app wiki.
*
* @return An API object that is equivalent to calling getAPIForSite(WikiSite)
*/
public Api getSiteApi() {
return getAPIForSite(getWikiSite());
}
@Nullable
public String getAppLanguageCode() {
return appLanguageState.getAppLanguageCode();
}
@NonNull
public String getAppOrSystemLanguageCode() {
String code = appLanguageState.getAppOrSystemLanguageCode();
// noinspection ConstantConditions
if (User.isLoggedIn() && !User.getUser().hasIdForLang(code)) {
getIdForLanguage(code);
}
return code;
}
@NonNull
public String getSystemLanguageCode() {
return appLanguageState.getSystemLanguageCode();
}
public void setAppLanguageCode(@Nullable String code) {
appLanguageState.setAppLanguageCode(code);
resetWikiSite();
}
private void getIdForLanguage(@NonNull final String code) {
final WikiSite wikiSite = WikiSite.forLanguageCode(code);
idClient.request(wikiSite, new UserIdClient.Callback() {
@Override
public void success(@NonNull Call<MwQueryResponse<UserIdClient.QueryUserInfo>> call, int id) {
User user = User.getUser();
if (user != null) {
user.putIdForLanguage(code, id);
L.v("Found user ID " + id + " for " + code);
}
}
@Override
public void failure(@NonNull Call<MwQueryResponse<UserIdClient.QueryUserInfo>> call,
@NonNull Throwable caught) {
L.e("Failed to get user ID for " + wikiSite.languageCode(), caught);
}
});
}
@Nullable
public String getAppOrSystemLanguageLocalizedName() {
return appLanguageState.getAppOrSystemLanguageLocalizedName();
}
@NonNull
public List<String> getMruLanguageCodes() {
return appLanguageState.getMruLanguageCodes();
}
@NonNull
public List<String> getAppMruLanguageCodes() {
return appLanguageState.getAppMruLanguageCodes();
}
public void setMruLanguageCode(@Nullable String code) {
appLanguageState.setMruLanguageCode(code);
}
@Nullable
public String getAppLanguageLocalizedName(String code) {
return appLanguageState.getAppLanguageLocalizedName(code);
}
@Nullable
public String getAppLanguageCanonicalName(String code) {
return appLanguageState.getAppLanguageCanonicalName(code);
}
public Database getDatabase() {
return database;
}
public <T> DatabaseClient<T> getDatabaseClient(Class<T> cls) {
if (!databaseClients.containsKey(cls)) {
DatabaseClient<?> client;
if (cls.equals(HistoryEntry.class)) {
client = new DatabaseClient<>(this, HistoryEntry.DATABASE_TABLE);
} else if (cls.equals(PageImage.class)) {
client = new DatabaseClient<>(this, PageImage.DATABASE_TABLE);
} else if (cls.equals(RecentSearch.class)) {
client = new DatabaseClient<>(this, RecentSearch.DATABASE_TABLE);
} else if (cls.equals(SavedPage.class)) {
client = new DatabaseClient<>(this, SavedPage.DATABASE_TABLE);
} else if (cls.equals(EditSummary.class)) {
client = new DatabaseClient<>(this, EditSummary.DATABASE_TABLE);
} else if (cls.equals(UserOption.class)) {
client = new DatabaseClient<>(this, UserOptionRow.DATABASE_TABLE);
} else if (cls.equals(UserOptionRow.class)) {
client = new DatabaseClient<>(this, UserOptionRow.HTTP_DATABASE_TABLE);
} else if (cls.equals(ReadingListPageRow.class)) {
client = new DatabaseClient<>(this, ReadingListPageRow.DATABASE_TABLE);
} else if (cls.equals(ReadingListPageHttpRow.class)) {
client = new DatabaseClient<>(this, ReadingListPageRow.HTTP_DATABASE_TABLE);
} else if (cls.equals(ReadingListPageDiskRow.class)) {
client = new DatabaseClient<>(this, ReadingListPageRow.DISK_DATABASE_TABLE);
} else if (cls.equals(ReadingListRow.class)) {
client = new DatabaseClient<>(this, ReadingListRow.DATABASE_TABLE);
} else {
throw new RuntimeException("No persister found for class " + cls.getCanonicalName());
}
databaseClients.put(cls, client);
}
//noinspection unchecked
return (DatabaseClient<T>) databaseClients.get(cls);
}
public RemoteConfig getRemoteConfig() {
return remoteConfig;
}
@NonNull public SharedPreferenceCookieManager getCookieManager() {
return SharedPreferenceCookieManager.getInstance();
}
public void logOut() {
L.v("logging out");
AccountUtil.removeAccount();
UserOptionDao.instance().clear();
getCookieManager().clearAllCookies();
User.clearUser();
}
public FunnelManager getFunnelManager() {
return funnelManager;
}
/**
* Get this app's unique install ID, which is a UUID that should be unique for each install
* of the app. Useful for anonymous analytics.
* @return Unique install ID for this app.
*/
public String getAppInstallID() {
String id = Prefs.getAppInstallId();
if (id == null) {
id = UUID.randomUUID().toString();
Prefs.setAppInstallId(id);
}
return id;
}
/**
* Get an integer-valued random ID. This is typically used to determine global EventLogging
* sampling, that is, whether the user's instance of the app sends any events or not. This is a
* pure technical measure which is necessary to prevent overloading EventLogging with too many
* events. This value will persist for the lifetime of the app.
*
* Don't use this method when running to determine whether or not the user falls into a control
* or test group in any kind of tests (such as A/B tests), as that would introduce sampling
* biases which would invalidate the test.
* @return Integer ID for event log sampling.
*/
@IntRange(from = 0)
public int getEventLogSamplingID() {
return EVENT_LOG_TESTING_ID;
}
/**
* Gets the currently-selected theme for the app.
* @return Theme that is currently selected, which is the actual theme ID that can
* be passed to setTheme() when creating an activity.
*/
@NonNull
public Theme getCurrentTheme() {
return currentTheme;
}
public boolean isCurrentThemeLight() {
return getCurrentTheme().isLight();
}
public boolean isCurrentThemeDark() {
return getCurrentTheme().isDark();
}
/**
* Sets the theme of the app. If the new theme is the same as the current theme, nothing happens.
* Otherwise, an event is sent to notify of the theme change.
*/
public void setCurrentTheme(@NonNull Theme theme) {
if (theme != currentTheme) {
currentTheme = theme;
Prefs.setThemeId(currentTheme.getMarshallingId());
UserOptionDao.instance().theme(theme);
bus.post(new ThemeChangeEvent());
}
}
public int getFontSizeMultiplier() {
return Prefs.getTextSizeMultiplier();
}
public void setFontSizeMultiplier(int multiplier) {
if (multiplier < FONT_SIZE_MULTIPLIER_MIN) {
multiplier = FONT_SIZE_MULTIPLIER_MIN;
} else if (multiplier > FONT_SIZE_MULTIPLIER_MAX) {
multiplier = FONT_SIZE_MULTIPLIER_MAX;
}
if (multiplier != Prefs.getTextSizeMultiplier()) {
Prefs.setTextSizeMultiplier(multiplier);
UserOptionDao.instance().fontSize(multiplier);
bus.post(new ChangeTextSizeEvent());
}
}
public void putCrashReportProperty(String key, String value) {
if (!ReleaseUtil.isPreBetaRelease()) {
crashReporter.putReportProperty(key, value);
}
}
public void checkCrashes(@NonNull Activity activity) {
if (!ReleaseUtil.isPreBetaRelease()) {
crashReporter.checkCrashes(activity);
}
}
public void runOnMainThread(Runnable runnable) {
new Handler(getMainLooper()).post(runnable);
}
/**
* Gets the current size of the app's font. This is given as a device-specific size (not "sp"),
* and can be passed directly to setTextSize() functions.
* @param window The window on which the font will be displayed.
* @return Actual current size of the font.
*/
public float getFontSize(Window window) {
return getFontSizeFromSp(window,
getResources().getDimension(R.dimen.textSize)) * (1.0f + getFontSizeMultiplier() * FONT_SIZE_FACTOR);
}
/**
* Gets whether EventLogging is currently enabled or disabled.
*
* @return A boolean that is true if EventLogging is enabled, and false if it is not.
*/
public boolean isEventLoggingEnabled() {
return Prefs.isEventLoggingEnabled();
}
public boolean isImageDownloadEnabled() {
return Prefs.isImageDownloadEnabled();
}
public boolean isLinkPreviewEnabled() {
return Prefs.isLinkPreviewEnabled();
}
public void resetWikiSite() {
wiki = null;
}
public OnboardingStateMachine getOnboardingStateMachine() {
return PrefsOnboardingStateMachine.getInstance();
}
public void listenForNotifications() {
notificationReceiver.startPollTask(this);
}
// For java-mwapi API requests.
// If adding a new header here (before this method is removed), make sure to duplicate it
// in the Retrofit header list (OkHttpConnectionFactory#CommonHeaderInterceptor).
@Deprecated
private Map<String, String> buildCustomHeadersMap(String acceptLanguage) {
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", getUserAgent());
if (isEventLoggingEnabled()) {
headers.put("X-WMF-UUID", getAppInstallID());
} else {
// Send do-not-track header if the user has opted out of event logging
headers.put("DNT", "1");
}
headers.put("Accept-Language", acceptLanguage);
return headers;
}
private void initAppLang() {
appLanguageState = new AppLanguageState(this);
boolean langNotSet = getAppLanguageCode() == null;
if (ReleaseUtil.isDevRelease() && langNotSet) {
setRandomAppLangCode();
}
}
private void setRandomAppLangCode() {
AppLanguageLookUpTable lut = new AppLanguageLookUpTable(this);
List<String> codes = lut.getCodes();
int index = new Random().nextInt(codes.size());
String code = codes.get(index);
setAppLanguageCode(code);
}
private void initExceptionHandling() {
crashReporter = new HockeyAppCrashReporter(getString(R.string.hockeyapp_app_id), consentAccessor());
crashReporter.registerCrashHandler(this);
L.setRemoteLogger(crashReporter);
}
private CrashReporter.AutoUploadConsentAccessor consentAccessor() {
return new CrashReporter.AutoUploadConsentAccessor() {
@Override
public boolean isAutoUploadPermitted() {
return Prefs.isCrashReportAutoUploadEnabled();
}
};
}
private void enableWebViewDebugging() {
if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.setWebContentsDebuggingEnabled(true);
}
}
private Theme unmarshalCurrentTheme() {
int id = Prefs.getThemeId();
Theme result = Theme.ofMarshallingId(id);
if (result == null) {
L.d("Theme id=" + id + " is invalid, using fallback.");
result = Theme.getFallback();
}
return result;
}
}