diff --git a/app/src/main/java/com/todoroo/astrid/activity/ShareLinkActivity.kt b/app/src/main/java/com/todoroo/astrid/activity/ShareLinkActivity.kt index 252ae2ae3..6cb0d0e12 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/ShareLinkActivity.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/ShareLinkActivity.kt @@ -6,12 +6,12 @@ import android.net.Uri import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import org.tasks.data.entity.Task import com.todoroo.astrid.service.TaskCreator import com.todoroo.astrid.utility.Constants import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.tasks.analytics.Firebase +import org.tasks.data.entity.Task import org.tasks.data.entity.TaskAttachment import org.tasks.files.FileHelper import org.tasks.intents.TaskIntents @@ -87,17 +87,17 @@ class ShareLinkActivity : AppCompatActivity() { startActivity(intent) } - private fun copyAttachment(intent: Intent): ArrayList = + private suspend fun copyAttachment(intent: Intent): ArrayList = intent.getParcelableExtra(Intent.EXTRA_STREAM) ?.let { copyAttachments(listOf(it)) } ?: arrayListOf() - private fun copyMultipleAttachments(intent: Intent): ArrayList = + private suspend fun copyMultipleAttachments(intent: Intent): ArrayList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) ?.let { copyAttachments(it) } ?: arrayListOf() - private fun copyAttachments(uris: List) = + private suspend fun copyAttachments(uris: List) = uris .filter { it.scheme == ContentResolver.SCHEME_CONTENT diff --git a/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt b/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt index aafed77d9..59530464f 100644 --- a/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt @@ -22,9 +22,11 @@ import org.tasks.compose.edit.AttachmentRow import org.tasks.data.dao.TaskAttachmentDao import org.tasks.data.entity.TaskAttachment import org.tasks.dialogs.AddAttachmentDialog +import org.tasks.extensions.Context.takePersistableUriPermission import org.tasks.files.FileHelper import org.tasks.preferences.Preferences import org.tasks.ui.TaskEditControlFragment +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint @@ -50,13 +52,18 @@ class FilesControlSet : TaskEditControlFragment() { AttachmentRow( attachments = viewState.attachments, openAttachment = { + Timber.d("Clicked open $it") FileHelper.startActionView( - requireActivity(), + context, if (Strings.isNullOrEmpty(it.uri)) null else Uri.parse(it.uri) ) }, - deleteAttachment = { viewModel.setAttachments(viewState.attachments - it) }, + deleteAttachment = { + Timber.d("Clicked delete $it") + viewModel.setAttachments(viewState.attachments - it) + }, addAttachment = { + Timber.d("Add attachment clicked") AddAttachmentDialog.newAddAttachmentDialog(this@FilesControlSet) .show(parentFragmentManager, FRAG_TAG_ADD_ATTACHMENT_DIALOG) }, @@ -69,20 +76,26 @@ class FilesControlSet : TaskEditControlFragment() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == AddAttachmentDialog.REQUEST_CAMERA || requestCode == AddAttachmentDialog.REQUEST_AUDIO) { if (resultCode == Activity.RESULT_OK) { - val uri = data!!.data - copyToAttachmentDirectory(uri) - FileHelper.delete(requireContext(), uri) + lifecycleScope.launch { + val uri = data!!.data + copyToAttachmentDirectory(uri) + FileHelper.delete(requireContext(), uri) + } } } else if (requestCode == AddAttachmentDialog.REQUEST_STORAGE || requestCode == AddAttachmentDialog.REQUEST_GALLERY) { if (resultCode == Activity.RESULT_OK) { - val clip = data!!.clipData - if (clip != null) { - for (i in 0 until clip.itemCount) { - val item = clip.getItemAt(i) - copyToAttachmentDirectory(item.uri) + lifecycleScope.launch { + val clip = data!!.clipData + if (clip != null) { + for (i in 0 until clip.itemCount) { + val item = clip.getItemAt(i) + requireContext().takePersistableUriPermission(item.uri) + copyToAttachmentDirectory(item.uri) + } + } else { + requireContext().takePersistableUriPermission(data.data!!) + copyToAttachmentDirectory(data.data) } - } else { - copyToAttachmentDirectory(data.data) } } } else { @@ -90,8 +103,17 @@ class FilesControlSet : TaskEditControlFragment() { } } - private fun copyToAttachmentDirectory(input: Uri?) { - newAttachment(FileHelper.copyToUri(requireContext(), preferences.attachmentsDirectory!!, input!!)) + private suspend fun copyToAttachmentDirectory(input: Uri?) { + val destination = preferences.attachmentsDirectory ?: return + newAttachment( + if (FileHelper.isInTree(requireContext(), destination, input!!)) { + Timber.d("$input already exists in $destination") + input + } else { + Timber.d("Copying $input to $destination") + FileHelper.copyToUri(requireContext(), destination, input) + } + ) } private fun newAttachment(output: Uri) { diff --git a/app/src/main/java/org/tasks/activities/CameraActivity.java b/app/src/main/java/org/tasks/activities/CameraActivity.java deleted file mode 100644 index 410bc8d60..000000000 --- a/app/src/main/java/org/tasks/activities/CameraActivity.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.tasks.activities; - -import android.annotation.SuppressLint; -import android.content.ContentResolver; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.provider.MediaStore; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.FileProvider; - -import com.todoroo.astrid.utility.Constants; - -import org.tasks.files.FileHelper; -import org.tasks.time.DateTime; - -import java.io.File; -import java.io.IOException; - -public class CameraActivity extends AppCompatActivity { - - private static final int REQUEST_CODE_CAMERA = 75; - private static final String EXTRA_URI = "extra_output"; - - private Uri uri; - - @SuppressLint("NewApi") - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (savedInstanceState != null) { - uri = savedInstanceState.getParcelable(EXTRA_URI); - } else { - try { - uri = - FileHelper.newFile( - this, - Uri.fromFile(getCacheDir()), - "image/jpeg", - new DateTime().toString("yyyyMMddHHmm"), - ".jpeg"); - } catch (IOException e) { - throw new RuntimeException(e); - } - if (!uri.getScheme().equals(ContentResolver.SCHEME_FILE)) { - throw new RuntimeException("Invalid Uri"); - } - final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - final Uri shared = - FileProvider.getUriForFile( - this, Constants.FILE_PROVIDER_AUTHORITY, new File(uri.getPath())); - intent.putExtra(MediaStore.EXTRA_OUTPUT, shared); - intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - startActivityForResult(intent, REQUEST_CODE_CAMERA); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == REQUEST_CODE_CAMERA) { - if (resultCode == RESULT_OK) { - final Intent intent = new Intent(); - intent.setData(uri); - setResult(RESULT_OK, intent); - } - finish(); - } else { - super.onActivityResult(requestCode, resultCode, data); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - - outState.putParcelable(EXTRA_URI, uri); - } -} diff --git a/app/src/main/java/org/tasks/activities/CameraActivity.kt b/app/src/main/java/org/tasks/activities/CameraActivity.kt new file mode 100644 index 000000000..6f70fe034 --- /dev/null +++ b/app/src/main/java/org/tasks/activities/CameraActivity.kt @@ -0,0 +1,83 @@ +package org.tasks.activities + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import androidx.lifecycle.lifecycleScope +import com.todoroo.astrid.utility.Constants +import kotlinx.coroutines.launch +import org.tasks.files.FileHelper.newFile +import org.tasks.time.DateTime +import java.io.File +import java.io.IOException + +class CameraActivity : AppCompatActivity() { + private var uri: Uri? = null + + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState != null) { + uri = savedInstanceState.getParcelable(EXTRA_URI) + } else { + lifecycleScope.launch { + try { + uri = + newFile( + this@CameraActivity, + Uri.fromFile(cacheDir), + "image/jpeg", + DateTime().toString("yyyyMMddHHmm"), + ".jpeg" + ) + } catch (e: IOException) { + throw RuntimeException(e) + } + if (uri!!.scheme != ContentResolver.SCHEME_FILE) { + throw RuntimeException("Invalid Uri") + } + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + val shared = + FileProvider.getUriForFile( + this@CameraActivity, Constants.FILE_PROVIDER_AUTHORITY, File( + uri!!.path!! + ) + ) + intent.putExtra(MediaStore.EXTRA_OUTPUT, shared) + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + startActivityForResult(intent, REQUEST_CODE_CAMERA) + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE_CAMERA) { + if (resultCode == Activity.RESULT_OK) { + val intent = Intent() + intent.setData(uri) + setResult(Activity.RESULT_OK, intent) + } + finish() + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putParcelable(EXTRA_URI, uri) + } + + companion object { + private const val REQUEST_CODE_CAMERA = 75 + private const val EXTRA_URI = "extra_output" + } +} diff --git a/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt index 33315434a..39f6a3efd 100644 --- a/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt +++ b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt @@ -30,7 +30,6 @@ import com.todoroo.astrid.activity.TaskEditFragment.Companion.gesturesDisabled import org.tasks.R import org.tasks.compose.BeastModeBanner import org.tasks.data.entity.UserActivity -import org.tasks.extensions.Context.findActivity import org.tasks.files.FileHelper import org.tasks.fragments.CommentBarFragment import org.tasks.themes.TasksTheme @@ -123,14 +122,9 @@ fun TaskEditScreen( val context = LocalContext.current CommentsRow( comments = comments, - copyCommentToClipboard = { - copyToClipboard(context, R.string.comment, it) - }, + copyCommentToClipboard = { copyToClipboard(context, R.string.comment, it) }, deleteComment = deleteComment, - openImage = { - val activity = context.findActivity() ?: return@CommentsRow - FileHelper.startActionView(activity, it) - } + openImage = { FileHelper.startActionView(context, it) }, ) } BeastModeBanner( diff --git a/app/src/main/java/org/tasks/dialogs/AddAttachmentDialog.kt b/app/src/main/java/org/tasks/dialogs/AddAttachmentDialog.kt index 87b75baed..353df8bfb 100644 --- a/app/src/main/java/org/tasks/dialogs/AddAttachmentDialog.kt +++ b/app/src/main/java/org/tasks/dialogs/AddAttachmentDialog.kt @@ -72,6 +72,7 @@ class AddAttachmentDialog : DialogFragment() { activity = activity, initial = null, allowMultiple = true, + persistPermissions = true, ), REQUEST_STORAGE ) diff --git a/app/src/main/java/org/tasks/extensions/Context.kt b/app/src/main/java/org/tasks/extensions/Context.kt index 4f47ff085..0a22e1bc1 100644 --- a/app/src/main/java/org/tasks/extensions/Context.kt +++ b/app/src/main/java/org/tasks/extensions/Context.kt @@ -26,6 +26,7 @@ import com.todoroo.andlib.utility.AndroidUtilities.atLeastS import org.tasks.BuildConfig import org.tasks.R import org.tasks.notifications.NotificationManager.Companion.NOTIFICATION_CHANNEL_DEFAULT +import timber.log.Timber object Context { private const val HTTP = "http" @@ -149,4 +150,15 @@ object Context { ) } } + + fun Context.takePersistableUriPermission( + uri: Uri, + mode: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) { + try { + contentResolver.takePersistableUriPermission(uri, mode) + } catch (e: SecurityException) { + Timber.e(e) + } + } } diff --git a/app/src/main/java/org/tasks/files/FileHelper.kt b/app/src/main/java/org/tasks/files/FileHelper.kt index ffe515c28..537965ead 100644 --- a/app/src/main/java/org/tasks/files/FileHelper.kt +++ b/app/src/main/java/org/tasks/files/FileHelper.kt @@ -17,12 +17,15 @@ import com.google.common.collect.Iterables import com.google.common.io.ByteStreams import com.google.common.io.Files import com.todoroo.astrid.utility.Constants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.tasks.Strings.isNullOrEmpty import org.tasks.extensions.Context.safeStartActivity import timber.log.Timber import java.io.File import java.io.FileNotFoundException import java.io.IOException +import java.io.InputStream import java.util.* object FileHelper { @@ -30,9 +33,18 @@ object FileHelper { activity: Activity?, initial: Uri?, allowMultiple: Boolean = false, + persistPermissions: Boolean = false, vararg mimeTypes: String?, ): Intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + if (persistPermissions) { + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + } putExtra("android.content.extra.SHOW_ADVANCED", true) putExtra("android.content.extra.FANCY", true) putExtra("android.content.extra.SHOW_FILESIZE", true) @@ -130,6 +142,24 @@ object FileHelper { Files.getFileExtension(getFilename(context, uri)!!) } + suspend fun fileExists(context: Context, uri: Uri): Boolean = withContext(Dispatchers.IO) { + when (uri.scheme) { + ContentResolver.SCHEME_FILE -> { + File(uri.path!!).exists() + } + ContentResolver.SCHEME_CONTENT -> { + try { + val documentFile = DocumentFile.fromSingleUri(context, uri) + documentFile?.exists() == true && documentFile.length() > 0 + } catch (e: Exception) { + Timber.e(e) + false + } + } + else -> false + } + } + fun getMimeType(context: Context, uri: Uri): String? { val mimeType = context.contentResolver.getType(uri) if (!isNullOrEmpty(mimeType)) { @@ -139,28 +169,33 @@ object FileHelper { return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) } - fun startActionView(context: Activity, uri: Uri?) { + fun startActionView(context: Context, uri: Uri?) { var uri = uri ?: return val mimeType = getMimeType(context, uri) val intent = Intent(Intent.ACTION_VIEW) if (uri.scheme == ContentResolver.SCHEME_CONTENT) { - uri = copyToUri(context, Uri.fromFile(context.cacheDir), uri) + intent.setDataAndType(uri, mimeType) + } else { + val share = FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, File(uri.path)) + intent.setDataAndType(share, mimeType) } - val share = FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, File(uri.path)) - intent.setDataAndType(share, mimeType) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) context.safeStartActivity(intent) } @JvmStatic @Throws(IOException::class) - fun newFile( - context: Context, destination: Uri, mimeType: String?, baseName: String, extension: String?): Uri { + suspend fun newFile( + context: Context, destination: Uri, mimeType: String?, baseName: String, extension: String?): Uri = withContext(Dispatchers.IO) { val filename = getNonCollidingFileName(context, destination, baseName, extension) - return when (destination.scheme) { + when (destination.scheme) { "content" -> { val tree = DocumentFile.fromTreeUri(context, destination) - val f1 = tree!!.createFile(mimeType!!, filename) + ?: throw IOException("Failed to access directory: $destination") + if (!tree.canWrite()) { + throw IOException("No write permission for directory: $destination") + } + val f1 = tree.createFile(mimeType ?: "application/octet-stream", filename) ?: throw FileNotFoundException("Failed to create $filename") f1.uri } @@ -171,15 +206,16 @@ object FileHelper { } val f2 = File(dir.absolutePath + File.separator + filename) if (f2.createNewFile()) { - return Uri.fromFile(f2) + Uri.fromFile(f2) + } else { + throw FileNotFoundException("Failed to create $filename") } - throw FileNotFoundException("Failed to create $filename") } else -> throw IllegalArgumentException("Unknown URI scheme: " + destination.scheme) } } - fun copyToUri(context: Context, destination: Uri, input: Uri): Uri { + suspend fun copyToUri(context: Context, destination: Uri, input: Uri): Uri { val filename = getFilename(context, input) val basename = Files.getNameWithoutExtension(filename!!) try { @@ -197,7 +233,53 @@ object FileHelper { } } - fun copyStream(context: Context, input: Uri?, output: Uri?) { + suspend fun isInTree(context: Context, treeUri: Uri, documentUri: Uri): Boolean = withContext(Dispatchers.IO) { + if (treeUri.authority != documentUri.authority) { + return@withContext false + } + + try { + val tree = DocumentFile.fromTreeUri(context, treeUri) + ?: return@withContext false + + val documentFile = DocumentFile.fromSingleUri(context, documentUri) + ?: return@withContext false + + val name = documentFile.name ?: return@withContext false + val treeFile = tree.findFile(name) ?: return@withContext false + val contentResolver = context.contentResolver + contentResolver.openInputStream(documentUri)?.use { input1 -> + contentResolver.openInputStream(treeFile.uri)?.use { input2 -> + compareStreams(input1, input2) + } + } ?: false + } catch (e: Exception) { + Timber.e(e) + false + } + } + + private fun compareStreams(input1: InputStream, input2: InputStream): Boolean { + val buffer1 = ByteArray(8192) + val buffer2 = ByteArray(8192) + + while (true) { + val count1 = input1.read(buffer1) + val count2 = input2.read(buffer2) + + if (count1 != count2) { + return false + } + if (count1 == -1) { + return true + } + if (!buffer1.contentEquals(buffer2)) { + return false + } + } + } + + suspend fun copyStream(context: Context, input: Uri?, output: Uri?) = withContext(Dispatchers.IO) { val contentResolver = context.contentResolver try { val inputStream = contentResolver.openInputStream(input!!) diff --git a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt index a73ab9770..e46e6ad68 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Advanced.kt @@ -13,6 +13,7 @@ import org.tasks.calendars.CalendarEventProvider import org.tasks.data.dao.TaskDao import org.tasks.data.db.Database import org.tasks.etebase.EtebaseLocalCache +import org.tasks.extensions.Context.takePersistableUriPermission import org.tasks.extensions.Context.toast import org.tasks.files.FileHelper import org.tasks.injection.InjectingPreferenceFragment @@ -73,11 +74,7 @@ class Advanced : InjectingPreferenceFragment() { if (requestCode == REQUEST_CODE_FILES_DIR) { if (resultCode == Activity.RESULT_OK) { val uri = data!!.data!! - requireContext().contentResolver - .takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) + requireContext().takePersistableUriPermission(uri) preferences.setUri(R.string.p_attachment_dir, uri) updateAttachmentDirectory() } diff --git a/app/src/main/java/org/tasks/preferences/fragments/Backups.kt b/app/src/main/java/org/tasks/preferences/fragments/Backups.kt index 7650e7807..4c463abb8 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Backups.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Backups.kt @@ -12,6 +12,7 @@ import org.tasks.R import org.tasks.dialogs.ExportTasksDialog import org.tasks.dialogs.ImportTasksDialog import org.tasks.drive.DriveLoginActivity +import org.tasks.extensions.Context.takePersistableUriPermission import org.tasks.extensions.Context.toast import org.tasks.files.FileHelper import org.tasks.injection.InjectingPreferenceFragment @@ -176,11 +177,7 @@ class Backups : InjectingPreferenceFragment() { if (requestCode == REQUEST_CODE_BACKUP_DIR) { if (resultCode == RESULT_OK && data != null) { val uri = data.data!! - context?.contentResolver - ?.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) + context?.takePersistableUriPermission(uri) preferences.setUri(R.string.p_backup_dir, uri) updateBackupDirectory() viewModel.updateLocalBackup() diff --git a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt index 5b013a06b..6ea4f78b4 100644 --- a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt @@ -524,7 +524,7 @@ class TaskEditViewModel @Inject constructor( } } - fun addComment(message: String?, picture: Uri?) { + fun addComment(message: String?, picture: Uri?) = viewModelScope.launch(NonCancellable) { val userActivity = UserActivity() if (picture != null) { val output = FileHelper.copyToUri(context, preferences.attachmentsDirectory!!, picture) @@ -533,11 +533,7 @@ class TaskEditViewModel @Inject constructor( userActivity.message = message userActivity.targetId = task.uuid userActivity.created = currentTimeMillis() - viewModelScope.launch { - withContext(NonCancellable) { - userActivityDao.createNew(userActivity) - } - } + userActivityDao.createNew(userActivity) } fun hideBeastModeHint(click: Boolean) { @@ -621,10 +617,14 @@ class TaskEditViewModel @Inject constructor( init { viewModelScope.launch { - taskAttachmentDao.getAttachments(task.id).toPersistentSet().let { attachments -> - _originalState.update { it.copy(attachments = attachments) } - _viewState.update { it.copy(attachments = attachments) } - } + taskAttachmentDao + .getAttachments(task.id) + .filter { FileHelper.fileExists(context, Uri.parse(it.uri)) } + .toPersistentSet() + .let { attachments -> + _originalState.update { it.copy(attachments = attachments) } + _viewState.update { it.copy(attachments = attachments) } + } } if (!task.isNew) { viewModelScope.launch {