SES-697 - Add loading state when exporting logs (#1402)

* WIP

* Fixes #1401

* Cleanup from PR view

* Final cleanup

* Removed commented line of code & re-ordered comment

* Addressed PR feedback

* Re-allowed loading of avatars to throw exceptions rather than return null on failure

---------

Co-authored-by: = <=>
This commit is contained in:
Al Lansley 2024-04-03 09:53:20 +11:00 committed by GitHub
parent fef965bcb5
commit d3c8635748
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 108 additions and 51 deletions

View File

@ -478,9 +478,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.d("Loki-Avatar", "Uploading Avatar Finished"); Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE; return Unit.INSTANCE;
}); });
} catch (Exception exception) { } catch (Exception e) {
// Do nothing Log.e("Loki-Avatar", "Uploading avatar failed.");
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
} }
}); });
} }

View File

@ -5,9 +5,14 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isInvisible
import androidx.preference.Preference import androidx.preference.Preference
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
@ -67,6 +72,19 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() {
} }
} }
private fun updateExportButtonAndProgressBarUI(exportJobRunning: Boolean) {
this.activity?.runOnUiThread(Runnable {
// Change export logs button text
val exportLogsButton = this.activity?.findViewById(R.id.export_logs_button) as TextView?
if (exportLogsButton == null) { Log.w("Loki", "Could not find export logs button view.") }
exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.activity_help_settings__export_logs)
// Show progress bar
val exportProgressBar = this.activity?.findViewById(R.id.export_progress_bar) as ProgressBar?
exportProgressBar?.isInvisible = !exportJobRunning
})
}
private fun shareLogs() { private fun shareLogs() {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@ -76,7 +94,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() {
Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()
} }
.onAllGranted { .onAllGranted {
ShareLogsDialog().show(parentFragmentManager,"Share Logs Dialog") ShareLogsDialog(::updateExportButtonAndProgressBarUI).show(parentFragmentManager,"Share Logs Dialog")
} }
.execute() .execute()
} }

View File

@ -11,55 +11,73 @@ import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.isInvisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.BuildConfig import network.loki.messenger.BuildConfig
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.ExternalStorageUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.FileProviderUtil
import org.thoughtcrime.securesms.util.StreamUtil import org.thoughtcrime.securesms.util.StreamUtil
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Objects import java.util.Objects
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ShareLogsDialog : DialogFragment() {
class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() {
private val TAG = "ShareLogsDialog"
private var shareJob: Job? = null private var shareJob: Job? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_share_logs_title) title(R.string.dialog_share_logs_title)
text(R.string.dialog_share_logs_explanation) text(R.string.dialog_share_logs_explanation)
button(R.string.share, dismiss = false) { shareLogs() } button(R.string.share, dismiss = false) { runShareLogsJob() }
cancelButton { dismiss() } cancelButton { updateCallback(false) }
} }
private fun shareLogs() { // If the share logs dialog loses focus the job gets cancelled so we'll update the UI state
override fun onPause() {
super.onPause()
updateCallback(false)
}
private fun runShareLogsJob() {
// Cancel any existing share job that might already be running to start anew
shareJob?.cancel() shareJob?.cancel()
updateCallback(true)
shareJob = lifecycleScope.launch(Dispatchers.IO) { shareJob = lifecycleScope.launch(Dispatchers.IO) {
val persistentLogger = ApplicationContext.getInstance(context).persistentLogger val persistentLogger = ApplicationContext.getInstance(context).persistentLogger
try { try {
Log.d(TAG, "Starting share logs job...")
val context = requireContext() val context = requireContext()
val outputUri: Uri = ExternalStorageUtil.getDownloadUri() val outputUri: Uri = ExternalStorageUtil.getDownloadUri()
val mediaUri = getExternalFile() val mediaUri = getExternalFile() ?: return@launch
if (mediaUri == null) {
// show toast saying media saved
dismiss()
return@launch
}
val inputStream = persistentLogger.logs.get().byteInputStream() val inputStream = persistentLogger.logs.get().byteInputStream()
val updateValues = ContentValues() val updateValues = ContentValues()
// Add details into the output or media files as appropriate
if (outputUri.scheme == ContentResolver.SCHEME_FILE) { if (outputUri.scheme == ContentResolver.SCHEME_FILE) {
FileOutputStream(mediaUri.path).use { outputStream -> FileOutputStream(mediaUri.path).use { outputStream ->
StreamUtil.copy(inputStream, outputStream) StreamUtil.copy(inputStream, outputStream)
@ -73,6 +91,7 @@ class ShareLogsDialog : DialogFragment() {
} }
} }
} }
if (Build.VERSION.SDK_INT > 28) { if (Build.VERSION.SDK_INT > 28) {
updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
} }
@ -95,13 +114,35 @@ class ShareLogsDialog : DialogFragment() {
} }
startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
} }
dismiss()
} catch (e: Exception) { } catch (e: Exception) {
withContext(Main) { withContext(Main) {
Log.e("Loki", "Error saving logs", e) Log.e("Loki", "Error saving logs", e)
Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show()
} }
}
}.also { shareJob ->
shareJob.invokeOnCompletion { handler ->
// Note: Don't show Toasts here directly - use `withContext(Main)` or such if req'd
handler?.message.let { msg ->
if (shareJob.isCancelled) {
if (msg.isNullOrBlank()) {
Log.w(TAG, "Share logs job was cancelled.")
} else {
Log.d(TAG, "Share logs job was cancelled. Reason: $msg")
}
}
else if (shareJob.isCompleted) {
Log.d(TAG, "Share logs job completed. Msg: $msg")
}
else {
Log.w(TAG, "Share logs job finished while still Active. Msg: $msg")
}
}
// Regardless of the job's success it has now completed so update the UI
updateCallback(false)
dismiss() dismiss()
} }
} }
@ -158,5 +199,4 @@ class ShareLogsDialog : DialogFragment() {
return context.contentResolver.insert(outputUri, contentValues) return context.contentResolver.insert(outputUri, contentValues)
} }
} }

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<TextView <TextView
android:id="@+id/export_logs_button"
android:layout_gravity="center" android:layout_gravity="center"
style="@style/Widget.Session.Button.Common.Filled" style="@style/Widget.Session.Button.Common.Filled"
android:textStyle="bold" android:textStyle="bold"
@ -11,5 +12,6 @@
android:paddingHorizontal="@dimen/medium_spacing" android:paddingHorizontal="@dimen/medium_spacing"
android:paddingVertical="12dp" android:paddingVertical="12dp"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"/> android:layout_height="wrap_content" />
</FrameLayout> </FrameLayout>

View File

@ -1,23 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout
xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container" xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical" android:id="@+id/export_progress_container"
android:layout_width="match_parent" android:orientation="vertical"
android:layout_height="match_parent" android:layout_width="match_parent"
android:paddingBottom="16dp" android:layout_height="wrap_content"
android:gravity="bottom"> android:layout_gravity="bottom" >
<ProgressBar android:id="@+id/progress_bar" <ProgressBar
style="?android:attr/progressBarStyleHorizontal" android:id="@+id/export_progress_bar"
android:layout_width="match_parent" style="?android:attr/progressBarStyleHorizontal"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:indeterminate="true"/> android:layout_height="wrap_content"
android:indeterminate="true"
<TextView android:id="@+id/progress_text" android:visibility="invisible" />
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
tools:text="1345 messages so far"/>
</LinearLayout> </LinearLayout>

View File

@ -6,39 +6,38 @@
android:key="export_logs" android:key="export_logs"
android:title="@string/activity_help_settings__report_bug_title" android:title="@string/activity_help_settings__report_bug_title"
android:summary="@string/activity_help_settings__report_bug_summary" android:summary="@string/activity_help_settings__report_bug_summary"
android:widgetLayout="@layout/export_logs_widget"/> android:widgetLayout="@layout/export_logs_widget" />
<!-- Note: Having this as `android:layout` rather than `android:layoutWidget` allows it to fit the screen width -->
<Preference android:layout="@layout/preference_widget_progress" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="translate_session" android:key="translate_session"
android:title="@string/activity_help_settings__translate_session" android:title="@string/activity_help_settings__translate_session"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="feedback" android:key="feedback"
android:title="@string/activity_help_settings__feedback" android:title="@string/activity_help_settings__feedback"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="faq" android:key="faq"
android:title="@string/activity_help_settings__faq" android:title="@string/activity_help_settings__faq"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory> <PreferenceCategory>
<Preference <Preference
android:key="support" android:key="support"
android:title="@string/activity_help_settings__support" android:title="@string/activity_help_settings__support"
android:widgetLayout="@layout/preference_external_link" android:widgetLayout="@layout/preference_external_link" />
/>
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@ -8,9 +8,11 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsignal.utilities.Log;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@ -22,9 +24,9 @@ public class AvatarHelper {
private static final String AVATAR_DIRECTORY = "avatars"; private static final String AVATAR_DIRECTORY = "avatars";
public static InputStream getInputStreamFor(@NonNull Context context, @NonNull Address address) public static InputStream getInputStreamFor(@NonNull Context context, @NonNull Address address)
throws IOException throws FileNotFoundException
{ {
return new FileInputStream(getAvatarFile(context, address)); return new FileInputStream(getAvatarFile(context, address));
} }
public static List<File> getAvatarFiles(@NonNull Context context) { public static List<File> getAvatarFiles(@NonNull Context context) {

View File

@ -8,6 +8,7 @@ import androidx.annotation.Nullable;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.security.MessageDigest; import java.security.MessageDigest;
@ -24,7 +25,7 @@ public class ProfileContactPhoto implements ContactPhoto {
} }
@Override @Override
public InputStream openInputStream(Context context) throws IOException { public InputStream openInputStream(Context context) throws FileNotFoundException {
return AvatarHelper.getInputStreamFor(context, address); return AvatarHelper.getInputStreamFor(context, address);
} }