diff --git a/app/build.gradle b/app/build.gradle index 72e514f6e..081826dea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,16 +13,24 @@ dependencies { implementation 'fr.avianey.com.viewpagerindicator:library:2.4.1.1@aar' implementation 'in.yuvi:http.fluent:1.3' implementation 'com.github.chrisbanes:PhotoView:2.0.0' + implementation 'ch.acra:acra:4.9.2' + implementation 'org.mediawiki:api:1.3' implementation 'commons-codec:commons-codec:1.10' implementation 'com.github.pedrovgs:renderers:3.3.3' - implementation 'com.google.code.gson:gson:2.8.1' - implementation 'com.jakewharton.timber:timber:4.5.1' + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.jakewharton.timber:timber:4.4.0' implementation 'info.debatty:java-string-similarity:0.24' implementation 'com.borjabravo:readmoretextview:2.1.0' - implementation 'com.android.support.constraint:constraint-layout:1.1.0' + implementation 'com.android.support.constraint:constraint-layout:1.1.3' + + implementation 'org.slf4j:slf4j-api:1.7.25' + api ("com.github.tony19:logback-android-classic:1.1.1-6") { + exclude group: 'com.google.android', module: 'android' + } + implementation('com.mapbox.mapboxsdk:mapbox-android-sdk:5.5.0@aar') { transitive = true } diff --git a/app/prod/release/app-commons-v2.8.3-acra-prod-release.apk b/app/prod/release/app-commons-v2.8.3-acra-prod-release.apk new file mode 100644 index 000000000..5bf9f34db Binary files /dev/null and b/app/prod/release/app-commons-v2.8.3-acra-prod-release.apk differ diff --git a/app/prod/release/output.json b/app/prod/release/output.json new file mode 100644 index 000000000..467010ef8 --- /dev/null +++ b/app/prod/release/output.json @@ -0,0 +1 @@ +[{"outputType":{"type":"APK"},"apkInfo":{"type":"MAIN","splits":[],"versionCode":90,"versionName":"2.8.3","enabled":true,"outputFile":"app-commons-v2.8.3-acra-prod-release.apk","fullName":"prodRelease","baseName":"prod-release"},"path":"app-commons-v2.8.3-acra-prod-release.apk","properties":{}}] \ No newline at end of file diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt index 39b618718..476220d75 100644 --- a/app/proguard-rules.txt +++ b/app/proguard-rules.txt @@ -1,4 +1,70 @@ -dontobfuscate -keep class org.apache.http.** { *; } -dontwarn org.apache.http.** --keep class android.support.v7.widget.ShareActionProvider { *; } \ No newline at end of file +-keep class android.support.v7.widget.ShareActionProvider { *; } + +# --- Butter Knife --- +# Finder.castParam() is stripped when not needed and ProGuard notes it +# unnecessarily. When castParam() is needed, it's not stripped. e.g.: +# +# @OnItemSelected(value = R.id.history_entry_list) +# void foo(ListView bar) { +# L.d("baz"); +# } + +-dontnote butterknife.internal.** +# --- /Butter Knife --- + +# --- Retrofit2 --- +# Platform calls Class.forName on types which do not exist on Android to determine platform. +-dontnote retrofit2.Platform +# Platform used when running on Java 8 VMs. Will not be used at runtime. +-dontwarn retrofit2.Platform$Java8 +# Retain generic type information for use by reflection by converters and adapters. +-keepattributes Signature +# Retain declared checked exceptions for use by a Proxy instance. +-keepattributes Exceptions +# --- /Retrofit --- + +# --- OkHttp + Okio --- +-dontwarn okhttp3.** +-dontwarn okio.** +# --- /OkHttp + Okio --- + +# --- Gson --- +# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg + +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { *; } + +# Prevent proguard from stripping interface information from TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer +# --- /Gson --- + + +# --- /logback --- + +-keep class ch.qos.** { *; } +-keep class org.slf4j.** { *; } +-keepattributes *Annotation* + +-dontwarn ch.qos.logback.core.net.* + +# --- /acra --- +-keep class org.acra.** { *; } +-keepattributes SourceFile,LineNumberTable +-keepattributes *Annotation* diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e80e2a84f..397bcd4bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,6 +122,8 @@ android:name=".achievements.AchievementsActivity" android:label="@string/Achievements" /> + + + + = Build.VERSION_CODES.O) { + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL); + if (channel == null) { + channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID_ALL, + context.getString(R.string.notifications_channel_name_all), NotificationManager.IMPORTANCE_DEFAULT); + manager.createNotificationChannel(channel); + } + } + } /** * Helps in setting up LeakCanary library diff --git a/app/src/main/java/fr/free/nrw/commons/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java index 1fc2d1f99..130028749 100644 --- a/app/src/main/java/fr/free/nrw/commons/Utils.java +++ b/app/src/main/java/fr/free/nrw/commons/Utils.java @@ -148,39 +148,6 @@ public class Utils { return PreferenceManager.getDefaultSharedPreferences(context).getBoolean("theme", false); } - /** - * Will be used to fetch the logs generated by the app ever since the beginning of times.... - * i.e. since the time the app started. - * - * @return String containing all the logs since the time the app started - */ - public static String getAppLogs() { - final String processId = Integer.toString(android.os.Process.myPid()); - - StringBuilder stringBuilder = new StringBuilder(); - - try { - String[] command = new String[]{"logcat","-d","-v","threadtime"}; - - Process process = Runtime.getRuntime().exec(command); - - BufferedReader bufferedReader = new BufferedReader( - new InputStreamReader(process.getInputStream()) - ); - - String line; - while ((line = bufferedReader.readLine()) != null) { - if (line.contains(processId)) { - stringBuilder.append(line); - } - } - } catch (IOException ioe) { - Timber.e("getAppLogs failed", ioe); - } - - return stringBuilder.toString(); - } - public static void rateApp(Context context) { final String appPackageName = BuildConfig.class.getPackage().getName(); try { diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java new file mode 100644 index 000000000..ddf16bc4d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java @@ -0,0 +1,23 @@ +package fr.free.nrw.commons.concurrency; + +import android.support.annotation.NonNull; + +import fr.free.nrw.commons.BuildConfig; + +public class BackgroundPoolExceptionHandler implements ExceptionHandler { + /** + * If an exception occurs on a background thread, this handler will crash for debug builds + * but fail silently for release builds. + * @param t + */ + @Override + public void onException(@NonNull final Throwable t) { + //Crash for debug build + if (BuildConfig.DEBUG) { + Thread thread = new Thread(() -> { + throw new RuntimeException(t); + }); + thread.start(); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java new file mode 100644 index 000000000..80931b1c1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java @@ -0,0 +1,39 @@ +package fr.free.nrw.commons.concurrency; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; + +class ExceptionAwareThreadPoolExecutor extends ScheduledThreadPoolExecutor { + + private final ExceptionHandler exceptionHandler; + + public ExceptionAwareThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory, + ExceptionHandler exceptionHandler) { + super(corePoolSize, threadFactory); + this.exceptionHandler = exceptionHandler; + } + + @Override + protected void afterExecute(Runnable r, Throwable t) { + super.afterExecute(r, t); + if (t == null && r instanceof Future) { + try { + Future future = (Future) r; + if (future.isDone()) future.get(); + } catch (CancellationException | InterruptedException e) { + //ignore + } catch (ExecutionException e) { + t = e.getCause() != null ? e.getCause() : e; + } catch (Exception e) { + t = e; + } + } + + if (t != null) { + exceptionHandler.onException(t); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java new file mode 100644 index 000000000..cc5a4c008 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.concurrency; + +import android.support.annotation.NonNull; + +public interface ExceptionHandler { + void onException(@NonNull Throwable t); +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java new file mode 100644 index 000000000..e0fd495ef --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java @@ -0,0 +1,124 @@ +package fr.free.nrw.commons.concurrency; + +import android.support.annotation.NonNull; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * This class is a thread pool which provides some additional features: + * - it sets the thread priority to a value lower than foreground priority by default, or you can + * supply your own priority + * - it gives you a way to handle exceptions thrown in the thread pool + */ + +public class ThreadPoolService implements Executor { + private final ScheduledThreadPoolExecutor backgroundPool; + + private ThreadPoolService(final Builder b) { + backgroundPool = new ExceptionAwareThreadPoolExecutor(b.poolSize, + new ThreadFactory() { + int count = 0; + @Override + public Thread newThread(@NonNull Runnable r) { + count++; + Thread t = new Thread(r, String.format("%s-%s", b.name, count)); + //If the priority is specified out of range, we set the thread priority to Thread.MIN_PRIORITY + //It's done prevent IllegalArgumentException and to prevent setting of improper high priority for a less priority task + t.setPriority(b.priority > Thread.MAX_PRIORITY || b.priority < Thread.MIN_PRIORITY ? + Thread.MIN_PRIORITY : b.priority); + return t; + } + }, b.exceptionHandler); + } + + public ScheduledFuture schedule(Callable callable, long time, TimeUnit timeUnit) { + return backgroundPool.schedule(callable, time, timeUnit); + } + + public ScheduledFuture schedule(Runnable runnable) { + return schedule(runnable, 0, TimeUnit.SECONDS); + } + + public ScheduledFuture schedule(Runnable runnable, long time, TimeUnit timeUnit) { + return backgroundPool.schedule(runnable, time, timeUnit); + } + + public ScheduledFuture scheduleAtFixedRate(final Runnable task, long initialDelay, + long period, final TimeUnit timeUnit) { + return backgroundPool.scheduleAtFixedRate(task, initialDelay, period, timeUnit); + } + + public ScheduledThreadPoolExecutor executor() { + return backgroundPool; + } + + public void shutdown(){ + backgroundPool.shutdown(); + } + + @Override + public void execute(Runnable command) { + backgroundPool.execute(command); + } + + /** + * Builder class for {@link ThreadPoolService} + */ + public static class Builder { + //Required + private final String name; + + //Optional + private int poolSize = 1; + private int priority = Thread.MIN_PRIORITY; + private ExceptionHandler exceptionHandler = null; + + /** + * @param name the name of the threads in the service. if there are N threads, + * the thread names will be like name-1, name-2, name-3,...,name-N + */ + public Builder(@NonNull String name) { + this.name = name; + } + + /** + * @param poolSize the number of threads to keep in the pool + * @throws IllegalArgumentException if size of pool <=0 + */ + public Builder setPoolSize(int poolSize) throws IllegalArgumentException { + if (poolSize <= 0) { + throw new IllegalArgumentException("Pool size must be grater than 0"); + } + this.poolSize = poolSize; + return this; + } + + /** + * @param priority Priority of the threads in the service. You can supply a constant from + * {@link java.lang.Thread} or + * specify your own priority in the range 1(MIN_PRIORITY) to 10(MAX_PRIORITY) + * By default, the priority is set to {@link java.lang.Thread#MIN_PRIORITY} + */ + public Builder setPriority(int priority) { + this.priority = priority; + return this; + } + + /** + * @param handler The handler to use to handle exceptions in the service + */ + public Builder setExceptionHandler(ExceptionHandler handler) { + this.exceptionHandler = handler; + return this; + } + + public ThreadPoolService build() { + return new ThreadPoolService(this); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index a6bc4f59d..da7dbffeb 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -152,4 +152,15 @@ public class CommonsApplicationModule { public WikidataEditListener provideWikidataEditListener() { return new WikidataEditListenerImpl(); } + + /** + * Provides app flavour. Can be used to alter flows in the app + * @return + */ + @Named("isBeta") + @Provides + @Singleton + public boolean provideIsBetaVariant() { + return BuildConfig.FLAVOR.equals("beta"); + } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java index 4bc84b7b1..c38c651a5 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesDao.java @@ -7,7 +7,6 @@ import android.database.sqlite.SQLiteDatabase; import android.os.RemoteException; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.util.Log; import java.util.ArrayList; import java.util.Date; @@ -17,6 +16,8 @@ import javax.inject.Inject; import javax.inject.Named; import javax.inject.Provider; +import timber.log.Timber; + /** * This class doesn't execute queries in database directly instead it contains the logic behind * inserting, deleting, searching data from recent searches database. @@ -62,12 +63,12 @@ public class RecentSearchesDao { if (recentSearch.getContentUri() == null) { throw new RuntimeException("tried to delete item with no content URI"); } else { - Log.d("QUERY_NAME",recentSearch.getContentUri()+"- delete tried"); + Timber.d("QUERY_NAME %s - delete tried", recentSearch.getContentUri()); db.delete(recentSearch.getContentUri(), null, null); - Log.d("QUERY_NAME",recentSearch.getQuery()+"- query deleted"); + Timber.d("QUERY_NAME %s - query deleted", recentSearch.getQuery()); } } catch (RemoteException e) { - Log.d("Exception",e+"- query deleted"); + Timber.e(e, "query deleted"); throw new RuntimeException(e); } finally { db.release(); diff --git a/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java new file mode 100644 index 000000000..407014db5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/CommonsLogSender.java @@ -0,0 +1,45 @@ +package fr.free.nrw.commons.logging; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.auth.SessionManager; + +/** + * Class responsible for sending logs to developers + */ +@Singleton +public class CommonsLogSender extends LogsSender { + private static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com"; + private static final String LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Android App (%s) Logs"; + private static final String BETA_LOGS_PRIVATE_EMAIL_SUBJECT = "Commons Beta Android App (%s) Logs"; + + @Inject + public CommonsLogSender(SessionManager sessionManager, + @Named("isBeta") boolean isBeta) { + super(sessionManager, isBeta); + + this.logFileName = isBeta ? "CommonsBetaAppLogs.zip" : "CommonsAppLogs.zip"; + String emailSubjectFormat = isBeta ? BETA_LOGS_PRIVATE_EMAIL_SUBJECT : LOGS_PRIVATE_EMAIL_SUBJECT; + String message = String.format(emailSubjectFormat, sessionManager.getUserName()); + this.emailSubject = message; + this.emailBody = message; + this.mailTo = LOGS_PRIVATE_EMAIL; + } + + /** + * Attach any extra meta information about user or device that might help in debugging + * @return + */ + @Override + protected String getExtraInfo() { + StringBuilder builder = new StringBuilder(); + builder.append("App Version Name: ") + .append(BuildConfig.VERSION_NAME) + .append("\n"); + + return builder.toString(); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java new file mode 100644 index 000000000..ce8174382 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.java @@ -0,0 +1,144 @@ +package fr.free.nrw.commons.logging; + +import android.support.annotation.NonNull; +import android.util.Log; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Locale; +import java.util.concurrent.Executor; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.rolling.FixedWindowRollingPolicy; +import ch.qos.logback.core.rolling.RollingFileAppender; +import ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy; +import timber.log.Timber; + +/** + * Extends Timber's debug tree to write logs to a file + */ +public class FileLoggingTree extends Timber.DebugTree implements LogLevelSettableTree { + private final Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + private int logLevel; + private final String logFileName; + private int fileSize; + private FixedWindowRollingPolicy rollingPolicy; + private final Executor executor; + + public FileLoggingTree(int logLevel, + String logFileName, + String logDirectory, + int fileSizeInKb, + Executor executor) { + this.logLevel = logLevel; + this.logFileName = logFileName; + this.fileSize = fileSizeInKb; + configureLogger(logDirectory); + this.executor = executor; + } + + /** + * Can be overridden to change file's log level + * @param logLevel + */ + @Override + public void setLogLevel(int logLevel) { + this.logLevel = logLevel; + } + + /** + * Check and log any message + * @param priority + * @param tag + * @param message + * @param t + */ + @Override + protected void log(final int priority, final String tag, @NonNull final String message, Throwable t) { + executor.execute(() -> logMessage(priority, tag, message)); + + } + + /** + * Log any message based on the priority + * @param priority + * @param tag + * @param message + */ + private void logMessage(int priority, String tag, String message) { + String messageWithTag = String.format("[%s] : %s", tag, message); + switch (priority) { + case Log.VERBOSE: + logger.trace(messageWithTag); + break; + case Log.DEBUG: + logger.debug(messageWithTag); + break; + case Log.INFO: + logger.info(messageWithTag); + break; + case Log.WARN: + logger.warn(messageWithTag); + break; + case Log.ERROR: + logger.error(messageWithTag); + break; + case Log.ASSERT: + logger.error(messageWithTag); + break; + } + } + + /** + * Checks if a particular log line should be logged in the file or not + * @param priority + * @return + */ + @Override + protected boolean isLoggable(int priority) { + return priority >= logLevel; + } + + /** + * Configures the logger with a file size rolling policy (SizeBasedTriggeringPolicy) + * https://github.com/tony19/logback-android/wiki + * @param logDir + */ + private void configureLogger(String logDir) { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + loggerContext.reset(); + + RollingFileAppender rollingFileAppender = new RollingFileAppender<>(); + rollingFileAppender.setContext(loggerContext); + rollingFileAppender.setFile(logDir + "/" + logFileName + ".0.log"); + + rollingPolicy = new FixedWindowRollingPolicy(); + rollingPolicy.setContext(loggerContext); + rollingPolicy.setMinIndex(1); + rollingPolicy.setMaxIndex(4); + rollingPolicy.setParent(rollingFileAppender); + rollingPolicy.setFileNamePattern(logDir + "/" + logFileName + ".%i.log"); + rollingPolicy.start(); + + SizeBasedTriggeringPolicy triggeringPolicy = new SizeBasedTriggeringPolicy<>(); + triggeringPolicy.setContext(loggerContext); + triggeringPolicy.setMaxFileSize(String.format(Locale.ENGLISH, "%dKB", fileSize)); + triggeringPolicy.start(); + + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(loggerContext); + encoder.setPattern("%-27(%date{ISO8601}) [%-5level] [%thread] %msg%n"); + encoder.start(); + + rollingFileAppender.setEncoder(encoder); + rollingFileAppender.setRollingPolicy(rollingPolicy); + rollingFileAppender.setTriggeringPolicy(triggeringPolicy); + rollingFileAppender.start(); + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) + LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + logger.addAppender(rollingFileAppender); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java new file mode 100644 index 000000000..83b8854cc --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogLevelSettableTree.java @@ -0,0 +1,8 @@ +package fr.free.nrw.commons.logging; + +/** + * Can be implemented to set the log level for file tree + */ +public interface LogLevelSettableTree { + void setLogLevel(int logLevel); +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java new file mode 100644 index 000000000..21b133aa2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogUtils.java @@ -0,0 +1,25 @@ +package fr.free.nrw.commons.logging; + +import android.content.Context; +import android.os.Environment; + +/** + * Returns the log directory + */ +public final class LogUtils { + private LogUtils() { + } + + /** + * Returns the directory for saving logs on the device + * @param isBeta + * @return + */ + public static String getLogDirectory(boolean isBeta) { + if(isBeta) { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/beta"; + } else { + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/logs/prod"; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java new file mode 100644 index 000000000..16c40cf44 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/logging/LogsSender.java @@ -0,0 +1,183 @@ +package fr.free.nrw.commons.logging; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.acra.collector.CrashReportData; +import org.acra.sender.ReportSender; +import org.apache.commons.codec.Charsets; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import fr.free.nrw.commons.auth.SessionManager; +import timber.log.Timber; + +/** + * Abstract class that implements Acra's log sender + */ +public abstract class LogsSender implements ReportSender { + + String mailTo; + String logFileName; + String emailSubject; + String emailBody; + + private final SessionManager sessionManager; + private final boolean isBeta; + + LogsSender(SessionManager sessionManager, + boolean isBeta) { + this.sessionManager = sessionManager; + this.isBeta = isBeta; + } + + /** + * Overrides send method of ACRA's ReportSender to send logs + * @param context + * @param report + */ + @Override + public void send(@NonNull final Context context, @Nullable CrashReportData report) { + sendLogs(context, report); + } + + /** + * Gets zipped log files and sends it via email. Can be modified to change the send log mechanism + * @param context + * @param report + */ + private void sendLogs(Context context, CrashReportData report) { + final Uri logFileUri = getZippedLogFileUri(context, report); + if (logFileUri != null) { + sendEmail(context, logFileUri); + } + } + + /*** + * Provides any extra information that you want to send. The return value will be + * delivered inside the report verbatim + * + * @return + */ + protected abstract String getExtraInfo(); + + /** + * Fires an intent to send email with logs + * @param context + * @param logFileUri + */ + private void sendEmail(Context context, Uri logFileUri) { + String subject = emailSubject; + String body = emailBody; + + Intent emailIntent = new Intent(Intent.ACTION_SENDTO); + emailIntent.setData(Uri.fromParts("mailto", mailTo, null)); + emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + emailIntent.putExtra(Intent.EXTRA_TEXT, body); + emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri); + context.startActivity(emailIntent); + } + + /** + * Returns the URI for the zipped log file + * @param context + * @param report + * @return + */ + private Uri getZippedLogFileUri(Context context, CrashReportData report) { + try { + StringBuilder builder = new StringBuilder(); + if (report != null) { + attachCrashInfo(report, builder); + } + attachUserInfo(builder); + attachExtraInfo(builder); + byte[] metaData = builder.toString().getBytes(Charsets.UTF_8); + File zipFile = new File(context.getExternalFilesDir(null), logFileName); + writeLogToZipFile(metaData, zipFile); + return Uri.fromFile(zipFile); + } catch (IOException e) { + Timber.w(e, "Error in generating log file"); + } + return null; + } + + /** + * Checks if there are any pending crash reports and attaches them to the logs + * @param report + * @param builder + */ + private void attachCrashInfo(CrashReportData report, StringBuilder builder) { + if (report == null) { + return; + } + builder.append(report); + } + + /** + * Attaches username to the the meta_data file + * @param builder + */ + private void attachUserInfo(StringBuilder builder) { + builder.append("MediaWiki Username = ").append(sessionManager.getUserName()).append("\n"); + } + + /** + * Gets any extra meta information to be attached with the log files + * @param builder + */ + private void attachExtraInfo(StringBuilder builder) { + String infoToBeAttached = getExtraInfo(); + builder.append(infoToBeAttached); + builder.append("\n"); + } + + /** + * Zips the logs and meta information + * @param metaData + * @param zipFile + * @throws IOException + */ + private void writeLogToZipFile(byte[] metaData, File zipFile) throws IOException { + FileOutputStream fos = new FileOutputStream(zipFile); + BufferedOutputStream bos = new BufferedOutputStream(fos); + ZipOutputStream zos = new ZipOutputStream(bos); + File logDir = new File(LogUtils.getLogDirectory(isBeta)); + + if (!logDir.exists() || logDir.listFiles().length == 0) { + return; + } + + byte[] buffer = new byte[1024]; + for (File file : logDir.listFiles()) { + FileInputStream fis = new FileInputStream(file); + BufferedInputStream bis = new BufferedInputStream(fis); + zos.putNextEntry(new ZipEntry(file.getName())); + int length; + while ((length = bis.read(buffer)) > 0) { + zos.write(buffer, 0, length); + } + zos.closeEntry(); + bis.close(); + } + + //attach metadata as a separate file + zos.putNextEntry(new ZipEntry("meta_data.txt")); + zos.write(metaData); + zos.closeEntry(); + + zos.flush(); + zos.close(); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java index 3218ccf18..f2cddb5bd 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java @@ -8,7 +8,6 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; -import android.util.Log; import com.google.gson.Gson; @@ -212,8 +211,6 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { @Override public void setAuthCookie(String authCookie) { api.setAuthCookie(authCookie); - - Timber.d("Mediawiki auth cookie is %s", api.getAuthCookie()); } @Override @@ -893,7 +890,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { CustomApiResult result = api.upload(filename, file, dataLength, pageContents, editSummary, getCentralAuthToken(), getEditToken(), progressListener::onProgress); - Log.e("WTF", "Result: " + result.toString()); + Timber.wtf("Result: " + result.toString()); String resultStatus = result.getString("/api/upload/@result"); @@ -980,7 +977,7 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { new PageTitle(userName).getText()); HttpUrl.Builder urlBuilder = HttpUrl.parse(url).newBuilder(); urlBuilder.addQueryParameter("user", userName); - Log.i("url", urlBuilder.toString()); + Timber.i("Url %s", urlBuilder.toString()); Request request = new Request.Builder() .url(urlBuilder.toString()) .build(); diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CustomApiResult.java b/app/src/main/java/fr/free/nrw/commons/mwapi/CustomApiResult.java index a35270150..a36e8e0fd 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/CustomApiResult.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/CustomApiResult.java @@ -38,9 +38,6 @@ public class CustomApiResult { static CustomApiResult fromRequestBuilder(Http.HttpRequestBuilder builder, HttpClient client) throws IOException { - Timber.d("API request is %s", builder.toString()); - Timber.d("API params are %s", client.getParams()); - try { DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); Document doc = docBuilder.parse(builder.use(client).charset("utf-8").data("format", "xml").asResponse().getEntity().getContent()); diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/CustomMwApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/CustomMwApi.java index 21810d865..a424789ce 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/CustomMwApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/CustomMwApi.java @@ -141,8 +141,6 @@ public class CustomMwApi { } public CustomApiResult upload(String filename, InputStream file, long length, String text, String comment, String centralAuthToken, String token, ProgressListener uploadProgressListener) throws IOException { - Timber.d("Token being used is %s", token); - Http.HttpRequestBuilder builder = Http.multipart(apiURL) .data("action", "upload") .data("token", token) @@ -158,8 +156,6 @@ public class CustomMwApi { builder.file("file", filename, file); } - Timber.d("Final cookies are %s", client.getCookieStore().getCookies().toString()); - return CustomApiResult.fromRequestBuilder(builder, client); } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceRenderer.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceRenderer.java index 7170e4c02..050369be8 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceRenderer.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceRenderer.java @@ -7,7 +7,6 @@ import android.content.SharedPreferences; import android.support.v4.app.Fragment; import android.support.transition.TransitionManager; import android.support.v7.widget.PopupMenu; -import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; @@ -87,7 +86,7 @@ public class PlaceRenderer extends Renderer { protected void hookListeners(View view) { final View.OnClickListener listener = view12 -> { - Log.d("Renderer", "clicked"); + Timber.d("Renderer clicked"); TransitionManager.beginDelayedTransition(buttonLayout); if(buttonLayout.isShown()){ diff --git a/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java b/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java index e7c40f615..702aa2e47 100644 --- a/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/quiz/QuizResultActivity.java @@ -8,7 +8,6 @@ import android.net.Uri; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; -import android.util.Log; import com.dinuscxj.progressbar.CircleProgressBar; diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java index d35170adf..73ed289a7 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java @@ -2,41 +2,35 @@ package fr.free.nrw.commons.settings; import android.Manifest; import android.app.AlertDialog; -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.preference.SwitchPreference; import android.preference.EditTextPreference; import android.preference.ListPreference; import android.preference.Preference; import android.preference.PreferenceFragment; +import android.preference.SwitchPreference; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; -import android.support.v4.content.FileProvider; -import android.widget.Toast; - -import java.io.File; import javax.inject.Inject; import javax.inject.Named; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.di.ApplicationlessInjection; -import fr.free.nrw.commons.upload.FileUtils; +import fr.free.nrw.commons.logging.CommonsLogSender; +import fr.free.nrw.commons.utils.ViewUtil; public class SettingsFragment extends PreferenceFragment { private static final int REQUEST_CODE_WRITE_EXTERNAL_STORAGE = 100; @Inject @Named("default_preferences") SharedPreferences prefs; + @Inject + CommonsLogSender commonsLogSender; @Override public void onCreate(Bundle savedInstanceState) { @@ -99,28 +93,12 @@ public class SettingsFragment extends PreferenceFragment { Preference betaTesterPreference = findPreference("becomeBetaTester"); betaTesterPreference.setOnPreferenceClickListener(preference -> { - Utils.handleWebUrl(getActivity(),Uri.parse(getResources().getString(R.string.beta_opt_in_link))); - return true; + Utils.handleWebUrl(getActivity(), Uri.parse(getResources().getString(R.string.beta_opt_in_link))); + return true; }); Preference sendLogsPreference = findPreference("sendLogFile"); sendLogsPreference.setOnPreferenceClickListener(preference -> { - //first we need to check if we have the necessary permissions - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (ContextCompat.checkSelfPermission( - getActivity(), - Manifest.permission.WRITE_EXTERNAL_STORAGE) - == - PackageManager.PERMISSION_GRANTED) { - sendAppLogsViaEmail(); - } else { - //first get the necessary permission - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, - REQUEST_CODE_WRITE_EXTERNAL_STORAGE); - } - } else { - sendAppLogsViaEmail(); - } - + checkPermissionsAndSendLogs(); return true; }); } @@ -129,42 +107,31 @@ public class SettingsFragment extends PreferenceFragment { public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_CODE_WRITE_EXTERNAL_STORAGE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - { - sendAppLogsViaEmail(); + { + ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.log_collection_started)); } } } - private void sendAppLogsViaEmail() { - String appLogs = Utils.getAppLogs(); - File appLogsFile = FileUtils.createAndGetAppLogsFile(appLogs); - - Context applicationContext = getActivity().getApplicationContext(); - Uri appLogsFilePath = FileProvider.getUriForFile( - getActivity(), - applicationContext.getPackageName() + ".provider", - appLogsFile - ); - - //initialize the emailSelectorIntent - Intent emailSelectorIntent = new Intent(Intent.ACTION_SENDTO); - emailSelectorIntent.setData(Uri.parse("mailto:")); - //initialize the emailIntent - final Intent emailIntent = new Intent(Intent.ACTION_SEND); - // Logs must be sent to the PRIVATE email. Please do not modify this without good reason! - emailIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{CommonsApplication.LOGS_PRIVATE_EMAIL}); - emailIntent.putExtra(Intent.EXTRA_SUBJECT, String.format(CommonsApplication.LOGS_PRIVATE_EMAIL_SUBJECT, BuildConfig.VERSION_NAME)); - emailIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - emailIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - emailIntent.setSelector( emailSelectorIntent ); - //adding the attachment to the intent - emailIntent.putExtra(Intent.EXTRA_STREAM, appLogsFilePath); - - try { - startActivity(Intent.createChooser(emailIntent, "Send mail..")); - } catch (ActivityNotFoundException e) { - Toast.makeText(getActivity(), R.string.no_email_client, Toast.LENGTH_SHORT).show(); + /** + * First checks for external storage permissions and then sends logs via email + */ + private void checkPermissionsAndSendLogs() { + //first we need to check if we have the necessary permissions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (ContextCompat.checkSelfPermission( + getActivity(), + Manifest.permission.WRITE_EXTERNAL_STORAGE) + == + PackageManager.PERMISSION_GRANTED) { + commonsLogSender.send(getActivity(), null); + } else { + //first get the necessary permission + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_CODE_WRITE_EXTERNAL_STORAGE); + } + } else { + commonsLogSender.send(getActivity(), null); } } - } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java index bbfd1b029..86f279d23 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadController.java @@ -15,7 +15,6 @@ import android.os.AsyncTask; import android.os.IBinder; import android.provider.MediaStore; import android.text.TextUtils; -import android.util.Log; import java.io.BufferedInputStream; import java.io.File; @@ -171,7 +170,7 @@ public class UploadController { //TODO: understand do we really need this code if (contribution.getDataLength() <= 0) { - Log.d("deneme","UploadController/doInBackground, contribution.getLocalUri():"+contribution.getLocalUri()); + Timber.d("UploadController/doInBackground, contribution.getLocalUri():" + contribution.getLocalUri()); AssetFileDescriptor assetFileDescriptor = contentResolver .openAssetFileDescriptor(Uri.fromFile(new File(contribution.getLocalUri().getPath())), "r"); if (assetFileDescriptor != null) { diff --git a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java index 79dad33e5..1e269bda2 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/ImageUtils.java @@ -126,6 +126,7 @@ public class ImageUtils { int allPixelsCount = bitmapWidth * bitmapHeight; int[] bitmapPixels = new int[allPixelsCount]; + Timber.d("total %s", Integer.toString(allPixelsCount)); bitmap.getPixels(bitmapPixels,0,bitmapWidth,0,0,bitmapWidth,bitmapHeight); boolean isImageDark = false; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60c5343ba..05d03aab4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,7 +239,7 @@ Save pictures taken with the in-app camera on your device Login to your account Send log file - Send log file to developers via email + Send log file to developers via email to help debug problems with the app. Note: logs may potentially contain identifying information No web browser found to open URL Error! URL not found Nominate for Deletion @@ -343,4 +343,9 @@ The number of images you have uploaded to Commons that were used in Wikimedia articles Error occurred! + Commons Notification + Storage Permission + We need your permission to access the external storage of your device in order to upload images. + + Log collection started. Please RESTART the app, perform action that you wish to log, and then tap \'Send Logs\' again