diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index da8b2934b1..d0d7ba60d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -54,15 +54,19 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.Storage; +import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.groups.OpenGroupManager; import org.thoughtcrime.securesms.home.HomeActivity; +import org.thoughtcrime.securesms.groups.OpenGroupManager; +import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; import org.thoughtcrime.securesms.jobs.FastJobStorage; import org.thoughtcrime.securesms.jobs.JobManagerFactories; import org.thoughtcrime.securesms.logging.AndroidLogger; +import org.thoughtcrime.securesms.logging.PersistentLogger; import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger; import org.thoughtcrime.securesms.notifications.BackgroundPollWorker; import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; @@ -126,6 +130,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO public Broadcaster broadcaster = null; private Job firebaseInstanceIdJob; private Handler conversationListNotificationHandler; + private PersistentLogger persistentLogger; @Inject LokiAPIDatabase lokiAPIDatabase; @Inject Storage storage; @@ -146,6 +151,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO return this.conversationListNotificationHandler; } + public PersistentLogger getPersistentLogger() { + return this.persistentLogger; + } + @Override public void onCreate() { DatabaseModule.init(this); @@ -281,7 +290,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } private void initializeLogging() { - Log.initialize(new AndroidLogger()); + if (persistentLogger == null) { + persistentLogger = new PersistentLogger(this); + } + Log.initialize(new AndroidLogger(), persistentLogger); } private void initializeCrashHandling() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java index 64702cc988..f0c083ca1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java @@ -123,7 +123,7 @@ class LogFile { return builder.toString(); } - private String readEntry() throws IOException { + String readEntry() throws IOException { try { Util.readFully(inputStream, ivBuffer); Util.readFully(inputStream, intBuffer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java new file mode 100644 index 0000000000..9fd5968f6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.java @@ -0,0 +1,250 @@ +package org.thoughtcrime.securesms.logging; + +import android.content.Context; + +import androidx.annotation.AnyThread; +import androidx.annotation.WorkerThread; + +import org.session.libsignal.utilities.ListenableFuture; +import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.NoExternalStorageException; +import org.session.libsignal.utilities.SettableFuture; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class PersistentLogger extends Log.Logger { + + private static final String TAG = PersistentLogger.class.getSimpleName(); + + private static final String LOG_V = "V"; + private static final String LOG_D = "D"; + private static final String LOG_I = "I"; + private static final String LOG_W = "W"; + private static final String LOG_E = "E"; + private static final String LOG_WTF = "A"; + + private static final String LOG_DIRECTORY = "log"; + private static final String FILENAME_PREFIX = "log-"; + private static final int MAX_LOG_FILES = 5; + private static final int MAX_LOG_SIZE = 300 * 1024; + private static final int MAX_LOG_EXPORT = 10_000; + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz"); + + private final Context context; + private final Executor executor; + private final byte[] secret; + + private LogFile.Writer writer; + + public PersistentLogger(Context context) { + this.context = context.getApplicationContext(); + this.secret = LogSecretProvider.getOrCreateAttachmentSecret(context); + this.executor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "PersistentLogger"); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }); + + executor.execute(this::initializeWriter); + } + + @Override + public void v(String tag, String message, Throwable t) { + write(LOG_V, tag, message, t); + } + + @Override + public void d(String tag, String message, Throwable t) { + write(LOG_D, tag, message, t); + } + + @Override + public void i(String tag, String message, Throwable t) { + write(LOG_I, tag, message, t); + } + + @Override + public void w(String tag, String message, Throwable t) { + write(LOG_W, tag, message, t); + } + + @Override + public void e(String tag, String message, Throwable t) { + write(LOG_E, tag, message, t); + } + + @Override + public void wtf(String tag, String message, Throwable t) { + write(LOG_WTF, tag, message, t); + } + + @Override + public void blockUntilAllWritesFinished() { + CountDownLatch latch = new CountDownLatch(1); + + executor.execute(latch::countDown); + + try { + latch.await(); + } catch (InterruptedException e) { + android.util.Log.w(TAG, "Failed to wait for all writes."); + } + } + + @WorkerThread + public ListenableFuture<String> getLogs() { + final SettableFuture<String> future = new SettableFuture<>(); + + executor.execute(() -> { + StringBuilder builder = new StringBuilder(); + long entriesWritten = 0; + + try { + File[] logs = getSortedLogFiles(); + for (int i = logs.length - 1; i >= 0 && entriesWritten <= MAX_LOG_EXPORT; i--) { + try { + LogFile.Reader reader = new LogFile.Reader(secret, logs[i]); + String entry; + while ((entry = reader.readEntry()) != null) { + entriesWritten++; + builder.append(entry).append('\n'); + } + } catch (IOException e) { + android.util.Log.w(TAG, "Failed to read log at index " + i + ". Removing reference."); + logs[i].delete(); + } + } + + future.set(builder.toString()); + } catch (NoExternalStorageException e) { + future.setException(e); + } + }); + + return future; + } + + @WorkerThread + private void initializeWriter() { + try { + writer = new LogFile.Writer(secret, getOrCreateActiveLogFile()); + } catch (NoExternalStorageException | IOException e) { + android.util.Log.e(TAG, "Failed to initialize writer.", e); + } + } + + @AnyThread + private void write(String level, String tag, String message, Throwable t) { + executor.execute(() -> { + try { + if (writer == null) { + return; + } + + if (writer.getLogSize() >= MAX_LOG_SIZE) { + writer.close(); + writer = new LogFile.Writer(secret, createNewLogFile()); + trimLogFilesOverMax(); + } + + for (String entry : buildLogEntries(level, tag, message, t)) { + writer.writeEntry(entry); + } + + } catch (NoExternalStorageException e) { + android.util.Log.w(TAG, "Cannot persist logs.", e); + } catch (IOException e) { + android.util.Log.w(TAG, "Failed to write line. Deleting all logs and starting over."); + deleteAllLogs(); + initializeWriter(); + } + }); + } + + private void trimLogFilesOverMax() throws NoExternalStorageException { + File[] logs = getSortedLogFiles(); + if (logs.length > MAX_LOG_FILES) { + for (int i = MAX_LOG_FILES; i < logs.length; i++) { + logs[i].delete(); + } + } + } + + private void deleteAllLogs() { + try { + File[] logs = getSortedLogFiles(); + for (File log : logs) { + log.delete(); + } + } catch (NoExternalStorageException e) { + android.util.Log.w(TAG, "Was unable to delete logs.", e); + } + } + + private File getOrCreateActiveLogFile() throws NoExternalStorageException { + File[] logs = getSortedLogFiles(); + if (logs.length > 0) { + return logs[0]; + } + + return createNewLogFile(); + } + + private File createNewLogFile() throws NoExternalStorageException { + return new File(getOrCreateLogDirectory(), FILENAME_PREFIX + System.currentTimeMillis()); + } + + private File[] getSortedLogFiles() throws NoExternalStorageException { + File[] logs = getOrCreateLogDirectory().listFiles(); + if (logs != null) { + Arrays.sort(logs, (o1, o2) -> o2.getName().compareTo(o1.getName())); + return logs; + } + return new File[0]; + } + + private File getOrCreateLogDirectory() throws NoExternalStorageException { + File logDir = new File(context.getCacheDir(), LOG_DIRECTORY); + if (!logDir.exists() && !logDir.mkdir()) { + throw new NoExternalStorageException("Unable to create log directory."); + } + + return logDir; + } + + private List<String> buildLogEntries(String level, String tag, String message, Throwable t) { + List<String> entries = new LinkedList<>(); + Date date = new Date(); + + entries.add(buildEntry(level, tag, message, date)); + + if (t != null) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + t.printStackTrace(new PrintStream(outputStream)); + + String trace = new String(outputStream.toByteArray()); + String[] lines = trace.split("\\n"); + + for (String line : lines) { + entries.add(buildEntry(level, tag, line, date)); + } + } + + return entries; + } + + private String buildEntry(String level, String tag, String message, Date date) { + return DATE_FORMAT.format(date) + ' ' + level + ' ' + tag + ": " + message; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index de059b217b..a45efbc290 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -85,6 +85,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { helpTranslateButton.setOnClickListener { helpTranslate() } seedButton.setOnClickListener { showSeed() } clearAllDataButton.setOnClickListener { clearAllData() } + supportButton.setOnClickListener { shareLogs() } val isLightMode = UiModeUtilities.isDayUiMode(this) oxenLogoImageView.setImageResource(if (isLightMode) R.drawable.oxen_light_mode else R.drawable.oxen_dark_mode) versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") @@ -321,6 +322,10 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { ClearAllDataDialog().show(supportFragmentManager, "Clear All Data Dialog") } + private fun shareLogs() { + ShareLogsDialog().show(supportFragmentManager,"Share Logs Dialog") + } + // endregion private inner class DisplayNameEditActionModeCallback: ActionMode.Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt new file mode 100644 index 0000000000..9ff7ebbc43 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.preferences + +import android.content.Intent +import android.os.Build +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope +import kotlinx.android.synthetic.main.dialog_share_logs.view.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.providers.BlobProvider + +class ShareLogsDialog : BaseDialog() { + + private var shareJob: Job? = null + + override fun setContentView(builder: AlertDialog.Builder) { + val contentView = + LayoutInflater.from(requireContext()).inflate(R.layout.dialog_share_logs, null) + contentView.cancelButton.setOnClickListener { + dismiss() + } + contentView.shareButton.setOnClickListener { + // start the export and share + shareLogs() + } + builder.setView(contentView) + builder.setCancelable(false) + } + + private fun shareLogs() { + shareJob?.cancel() + shareJob = lifecycleScope.launch(Dispatchers.IO) { + val persistentLogger = ApplicationContext.getInstance(context).persistentLogger + try { + val logs = persistentLogger.logs.get() + val fileName = "${Build.MANUFACTURER}-${Build.DEVICE}-API${Build.VERSION.SDK_INT}-v${BuildConfig.VERSION_NAME}.log" + val logUri = BlobProvider().forData(logs.toByteArray()) + .withFileName(fileName) + .withMimeType("text/plain") + .createForSingleSessionOnDisk(requireContext(),null) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, logUri) + type = "text/plain" + } + + dismiss() + + startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) + } catch (e: Exception) { + Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() + dismiss() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 39c229df9c..706e4961b0 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -227,6 +227,18 @@ android:gravity="center" android:text="@string/activity_settings_survey_feedback" /> + <TextView + android:padding="@dimen/small_spacing" + android:id="@+id/supportButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/medium_spacing" + android:textColor="@color/text" + android:textSize="@dimen/medium_font_size" + android:textStyle="bold" + android:gravity="center" + android:text="@string/activity_settings_support" /> + <TextView android:padding="@dimen/small_spacing" android:id="@+id/helpTranslateButton" diff --git a/app/src/main/res/layout/dialog_share_logs.xml b/app/src/main/res/layout/dialog_share_logs.xml new file mode 100644 index 0000000000..1d8cf124c1 --- /dev/null +++ b/app/src/main/res/layout/dialog_share_logs.xml @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:background="@drawable/default_dialog_background_inset" + android:gravity="center_horizontal" + android:orientation="vertical" + android:elevation="4dp" + android:padding="32dp"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/dialog_share_logs_title" + android:textColor="@color/text" + android:textStyle="bold" + android:textSize="@dimen/medium_font_size" /> + + <TextView + android:id="@+id/dialogDescriptionText" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/large_spacing" + android:text="@string/dialog_share_logs_explanation" + android:textColor="@color/text" + android:textSize="@dimen/small_font_size" + android:textAlignment="center" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/large_spacing" + android:orientation="horizontal"> + + <Button + style="@style/Widget.Session.Button.Dialog.Unimportant" + android:id="@+id/cancelButton" + android:layout_width="0dp" + android:layout_height="@dimen/small_button_height" + android:layout_weight="1" + android:text="@string/cancel" /> + + <Button + style="@style/Widget.Session.Button.Dialog.Unimportant" + android:id="@+id/shareButton" + android:layout_width="0dp" + android:layout_height="@dimen/small_button_height" + android:layout_weight="1" + android:layout_marginStart="@dimen/medium_spacing" + android:text="@string/share" /> + + <com.github.ybq.android.spinkit.SpinKitView + style="@style/SpinKitView.Small.ThreeBounce" + android:id="@+id/progressBar" + android:layout_width="0dp" + android:layout_height="@dimen/small_button_height" + android:layout_weight="1" + app:SpinKit_Color="@color/accent" + android:visibility="gone" /> + + </LinearLayout> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7c8afc1bb..4f376e3ddb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -899,5 +899,8 @@ <string name="delete_message_for_everyone">Delete for everyone</string> <string name="delete_message_for_me_and_recipient">Delete for me and %s</string> <string name="activity_settings_survey_feedback">Feedback/Survey</string> + <string name="activity_settings_support">Support</string> + <string name="dialog_share_logs_title">Share Logs</string> + <string name="dialog_share_logs_explanation">Would you like to export your application logs to be able to share for troubleshooting?</string> </resources> diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 0c200c588e..88510fb278 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -19,7 +19,6 @@ class JobQueue : JobDelegate { private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>() private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val attachmentDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher() private val scope = GlobalScope + SupervisorJob() private val queue = Channel<Job>(UNLIMITED) private val pendingJobIds = mutableSetOf<String>() @@ -39,18 +38,15 @@ class JobQueue : JobDelegate { scope.launch { val rxQueue = Channel<Job>(capacity = 4096) val txQueue = Channel<Job>(capacity = 4096) - val attachmentQueue = Channel<Job>(capacity = 4096) val receiveJob = processWithDispatcher(rxQueue, rxDispatcher) val txJob = processWithDispatcher(txQueue, txDispatcher) - val attachmentJob = processWithDispatcher(attachmentQueue, attachmentDispatcher) while (isActive) { for (job in queue) { when (job) { is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> txQueue.send(job) - is AttachmentDownloadJob -> attachmentQueue.send(job) - is MessageReceiveJob, is BatchMessageReceiveJob, is TrimThreadJob -> rxQueue.send(job) + is MessageReceiveJob, is TrimThreadJob, is BatchMessageReceiveJob, is AttachmentDownloadJob-> rxQueue.send(job) else -> throw IllegalStateException("Unexpected job type.") } } @@ -59,7 +55,6 @@ class JobQueue : JobDelegate { // The job has been cancelled receiveJob.cancel() txJob.cancel() - attachmentJob.cancel() } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt index f165c97540..56ec3ffafc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt @@ -10,7 +10,6 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.GroupUtil import org.session.libsignal.crypto.getRandomElementOrNull import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.successBackground import java.util.* import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture @@ -82,7 +81,6 @@ class ClosedGroupPollerV2 { val limit: Long = 12 * 60 * 60 * 1000 val a = (Companion.maxPollInterval - minPollInterval).toDouble() / limit.toDouble() val nextPollInterval = a * min(timeSinceLastMessage, limit) + minPollInterval - Log.d("Loki", "Next poll interval for closed group with public key: $groupPublicKey is ${nextPollInterval / 1000} s.") executorService?.schedule({ poll(groupPublicKey).success { pollRecursively(groupPublicKey) @@ -108,7 +106,7 @@ class ClosedGroupPollerV2 { } } promise.fail { - Log.d("Loki", "Polling failed for closed group with public key: $groupPublicKey due to error: $it.") + Log.d("Loki", "Polling failed for closed group due to error: $it.") } return promise.map { } }