diff --git a/build.gradle b/build.gradle index 615e1551..16d47386 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ buildscript { ext.rxkotlin_version = '2.1.0' ext.rx_preferences_version = '2.0.0-RC3' ext.timber_version = '4.5.1' + ext.androidx_work_version = "2.8.0" ext.abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2] diff --git a/data/build.gradle b/data/build.gradle index 226a20be..7787cd04 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -94,6 +94,10 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttp3_version" implementation 'io.michaelrocks:libphonenumber-android:8.13.47' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // work manager + implementation("androidx.work:work-runtime-ktx:$androidx_work_version") + implementation project(":android-smsmms") implementation project(':common') implementation project(':domain') diff --git a/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt b/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt deleted file mode 100644 index 2262732c..00000000 --- a/data/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2017 Moez Bhatti - * - * This file is part of QKSMS. - * - * QKSMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * QKSMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with QKSMS. If not, see . - */ -package dev.octoshrimpy.quik.extensions - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import androidx.core.net.toFile - -fun Uri.resourceExists(context: Context): Boolean { - var retVal: Boolean? = null - try { - when (this.scheme) { - ContentResolver.SCHEME_CONTENT -> { - retVal = context.contentResolver.query( - this, null, null, null, null - )?.use { it.moveToFirst() } - } - ContentResolver.SCHEME_FILE -> retVal = this.toFile().exists() - } - } catch (e: Exception) { /* nothing */ - } - - return retVal ?: false -} diff --git a/data/src/main/java/com/moez/QKSMS/manager/HousekeepingWorkManager.kt b/data/src/main/java/com/moez/QKSMS/manager/HousekeepingWorkManager.kt new file mode 100644 index 00000000..c9acbeed --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/manager/HousekeepingWorkManager.kt @@ -0,0 +1,85 @@ +package com.moez.QKSMS.manager + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.moez.QKSMS.manager.MediaRecorderManager.AUDIO_FILE_PREFIX +import dev.octoshrimpy.quik.repository.ScheduledMessageRepositoryImpl +import java.io.File +import java.util.concurrent.TimeUnit + +class HousekeepingWorkManager(appContext: Context, workerParams: WorkerParameters) + : Worker(appContext, workerParams) { + + companion object { + private val WORKER_TAG: String = HousekeepingWorkManager::class.java.simpleName + + fun register(context: Context) { + // don't check return value because, well, we can't do much about a failure + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORKER_TAG, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequest.Builder( + HousekeepingWorkManager::class.java, + 24, + TimeUnit.HOURS + ) + .setConstraints( + Constraints.Builder() + // idle device constraint kinda guarantees quik won't be in use + // as files are deleted (primarily for deleting audio recordings) + .setRequiresDeviceIdle(true) + // good citizens don't use up low batteries + .setRequiresBatteryNotLow(true) + .build() + ) + .addTag(WORKER_TAG) + .build() + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORKER_TAG) + } + } + + override fun doWork(): Result { + removeOrphanedScheduledMessageAttachmentFiles() + + removeOrphanedComposeAudioRecording() + + return Result.success() + } + + private fun removeOrphanedScheduledMessageAttachmentFiles() { + // get list of all scheduled message ids + val scheduledMessageIds = ScheduledMessageRepositoryImpl().getAllScheduledMessageIdsSnapshot() + + // remove orphaned scheduled message dirs in files dir + File(applicationContext.filesDir,"") + // get dirs that match prefix 'scheduled-' + .listFiles { entry -> entry.isDirectory && entry.name.startsWith("scheduled-") } + // filter out any dirs that have an associated scheduled message in db + ?.filterNot { + scheduledMessageIds.contains(it.name.substringAfter('-').toLong()) + } + // recursively delete orphan dir + ?.forEach { it.deleteRecursively() } + } + + private fun removeOrphanedComposeAudioRecording() { + // find recording files in cache dir + applicationContext.cacheDir.listFiles { entry -> + entry.isFile && + entry.name.startsWith(AUDIO_FILE_PREFIX) && + entry.name.endsWith(MediaRecorderManager.AUDIO_FILE_SUFFIX) + } + // delete recording file + ?.forEach { it.delete() } + } + +} \ No newline at end of file diff --git a/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt index 676133c8..d0db5030 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt @@ -21,9 +21,14 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep sendAsGroup: Boolean, body: String, attachments: List - ) { + ): ScheduledMessage { Realm.getDefaultInstance().use { realm -> - val id = (realm.where(ScheduledMessage::class.java).max("id")?.toLong() ?: -1) + 1 + val id = (realm + .where(ScheduledMessage::class.java) + .max("id") + ?.toLong() ?: -1 + ) + 1 + val recipientsRealmList = RealmList(*recipients.toTypedArray()) val attachmentsRealmList = RealmList(*attachments.toTypedArray()) @@ -31,6 +36,14 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep attachmentsRealmList) realm.executeTransaction { realm.insertOrUpdate(message) } + + return message + } + } + + override fun updateScheduledMessage(scheduledMessage: ScheduledMessage) { + Realm.getDefaultInstance().use { realm -> + realm.executeTransaction { realm.insertOrUpdate(scheduledMessage) } } } @@ -73,6 +86,17 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep ids.forEach { deleteScheduledMessage(it) } } + override fun getAllScheduledMessageIdsSnapshot(): List { + Realm.getDefaultInstance().use { realm -> + return realm + .where(ScheduledMessage::class.java) + .sort("date") + .findAll() + .createSnapshot() + .map { it.id } + } + } + // Ensure to clear disposables when the repository is no longer needed fun clearDisposables() { disposables.clear() diff --git a/domain/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt b/domain/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt new file mode 100644 index 00000000..ba525a53 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/extensions/UriExtensions.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2017 Moez Bhatti + * + * This file is part of QKSMS. + * + * QKSMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QKSMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QKSMS. If not, see . + */ +package dev.octoshrimpy.quik.extensions + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.provider.ContactsContract +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.net.toFile + +fun Uri.isEmpty() = this == Uri.EMPTY + +fun Uri.isVCard(context: Context): Boolean = getType(context) == "text/x-vcard" + +fun Uri.isAudio(context: Context): Boolean = getType(context).startsWith("audio/") + +fun Uri.isImage(context: Context): Boolean = getType(context).startsWith("image/") + +fun Uri.isVideo(context: Context): Boolean = getType(context).startsWith("video/") + +fun Uri.isContact(context: Context): Boolean = + (getType(context).lowercase() == ContactsContract.Contacts.CONTENT_ITEM_TYPE) + +fun Uri.getResourceBytes(context: Context): ByteArray = + try { + context.contentResolver.openInputStream(this)?.use { + it.readBytes() + } ?: ByteArray(0) + } catch (e: Exception) { ByteArray(0) } + +fun Uri.resourceExists(context: Context): Boolean { + var retVal: Boolean? = null + try { + when (scheme) { + ContentResolver.SCHEME_CONTENT -> { + retVal = context.contentResolver.query( + this, null, null, null, null + )?.use { it.moveToFirst() } + } + ContentResolver.SCHEME_FILE -> retVal = this.toFile().exists() + } + } catch (e: Exception) { /* nothing */ + } + + return retVal ?: false +} + +fun Uri.getType(context: Context): String { + var retVal: String? = null + when (scheme) { + ContentResolver.SCHEME_CONTENT -> retVal = context.contentResolver.getType(this) + ContentResolver.SCHEME_FILE -> retVal = + MimeTypeMap.getSingleton().getMimeTypeFromExtension(this.toFile().extension) + } + return retVal ?: "application/octect-stream" +} + +@SuppressLint("Range") +fun Uri.getName(context: Context): String? { + var retVal: String? = null + try { + when (scheme) { + ContentResolver.SCHEME_CONTENT -> { + context.contentResolver.query( + this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null + )?.use { + it.moveToFirst() + retVal = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + ContentResolver.SCHEME_FILE -> retVal = this.toFile().name + } + } catch (e: Exception) { /* nothing */ } + + return retVal +} + +@SuppressLint("Range") +fun Uri.getSize(context: Context): Long { + var retVal: Long? = null + try { + when (scheme) { + ContentResolver.SCHEME_CONTENT -> { + context.contentResolver.query( + this, arrayOf(OpenableColumns.SIZE), null, null, null + )?.use { + it.moveToFirst() + retVal = it.getLong(it.getColumnIndex(OpenableColumns.SIZE)) + } + } + ContentResolver.SCHEME_FILE -> retVal = this.toFile().length() + } + } catch (e: Exception) { /* nothing */ + } + + return retVal ?: -1 +} + +@SuppressLint("Range") +fun Uri.contactToVCard(context: Context): Uri = + try { + Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_VCARD_URI, + context.contentResolver.query( + this, null, null, null, null + )?.use { + it.moveToFirst() + it.getString(it.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY)) ?: "" + } + ) + } catch (e: Exception) { + Uri.EMPTY + } + +fun Uri.getDefaultActivityIconForMimeType(context: Context): Drawable? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager + .queryIntentActivities( + Intent(Intent.ACTION_VIEW).setDataAndType(this, getType(context)), + PackageManager.ResolveInfoFlags.of( + PackageManager.MATCH_DEFAULT_ONLY.toLong() + ) + ).let { + if (it.size > 0) it[0].loadIcon(context.packageManager) + else null + } + } else { + context.packageManager + .resolveActivity( + Intent(Intent.ACTION_VIEW).setDataAndType(this, getType(context)), + PackageManager.MATCH_DEFAULT_ONLY) + ?.loadIcon(context.packageManager) + } diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/AddScheduledMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/AddScheduledMessage.kt index a18ffd07..1c4c7ac2 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/AddScheduledMessage.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/AddScheduledMessage.kt @@ -18,13 +18,23 @@ */ package dev.octoshrimpy.quik.interactor +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import dev.octoshrimpy.quik.extensions.getName +import dev.octoshrimpy.quik.extensions.mapNotNull import dev.octoshrimpy.quik.repository.ScheduledMessageRepository import io.reactivex.Flowable +import io.realm.RealmList +import java.io.File +import java.util.UUID import javax.inject.Inject class AddScheduledMessage @Inject constructor( private val scheduledMessageRepo: ScheduledMessageRepository, - private val updateScheduledMessageAlarms: UpdateScheduledMessageAlarms + private val updateScheduledMessageAlarms: UpdateScheduledMessageAlarms, + private val context: Context, ) : Interactor() { data class Params( @@ -36,13 +46,53 @@ class AddScheduledMessage @Inject constructor( val attachments: List ) + @SuppressLint("Range") override fun buildObservable(params: Params): Flowable<*> { + // have to 3-step save scheduled message return Flowable.just(params) - .map { - scheduledMessageRepo.saveScheduledMessage(it.date, it.subId, it.recipients, it.sendAsGroup, it.body, - it.attachments) - } - .flatMap { updateScheduledMessageAlarms.buildObservable(Unit) } + .map { // step 1 - save, as-is, to db to get primary key id + scheduledMessageRepo.saveScheduledMessage( + it.date, it.subId, it.recipients, it.sendAsGroup, it.body, it.attachments + ) + } + .map { scheduledMessageDb -> + // step 2 - copy attachments to app local storage + scheduledMessageDb.attachments = RealmList( + *scheduledMessageDb.attachments.mapNotNull { + val inUri = Uri.parse(it) + try { + // get filename of input uri or use random uuid on fail to get + val filename = inUri.getName(context) ?: UUID.randomUUID() + + // copy attachment data to app local dir - first, create dir + val localFile = File( + context.filesDir, + "scheduled-${scheduledMessageDb.id}/${UUID.randomUUID()}/${filename}" + ) + + // create directory tree in app local storage + localFile.parentFile?.mkdirs() + + val localUri = localFile.toUri() + + // copy attachment resource data to local file + context.contentResolver.openOutputStream(localUri, "w") + ?.use { outputStream -> + context.contentResolver.openInputStream(inUri) + ?.use { it.copyTo(outputStream, 4096) } + } + + localUri.toString() + } catch (e: Exception) { + it // on any error, use original uri string + } + }.toTypedArray() + ) + + // step 3 - update scheduled message with new attachment uris + scheduledMessageRepo.updateScheduledMessage(scheduledMessageDb) + } + .flatMap { updateScheduledMessageAlarms.buildObservable(Unit) } } } \ No newline at end of file diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/DeleteScheduledMessages.kt b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteScheduledMessages.kt new file mode 100644 index 00000000..7cb4d899 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteScheduledMessages.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 + * + * This file is part of QUIK. + * + * QUIK is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QUIK is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QUIK. If not, see . + */ +package dev.octoshrimpy.quik.interactor + +import android.annotation.SuppressLint +import android.content.Context +import dev.octoshrimpy.quik.repository.ScheduledMessageRepository +import io.reactivex.Flowable +import java.io.File +import javax.inject.Inject + +class DeleteScheduledMessages @Inject constructor( + private val scheduledMessageRepo: ScheduledMessageRepository, + private val context: Context, +) : Interactor>() { + + @SuppressLint("Range") + override fun buildObservable(scheduledMessageIds: List): Flowable<*> { + return Flowable.just(scheduledMessageIds) + .map { + try { + // for each message id to delete + it.forEach { + // recursively delete scheduled message top level dir + val topDir = File(context.filesDir, "scheduled-${it}") +// topDir.exists() && topDir.deleteRecursively() + } + } catch (e: Exception) { /* nothing */ } + + // delete the db entries + scheduledMessageRepo.deleteScheduledMessages(it) + } + } + +} \ No newline at end of file diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt index 7ea2ec33..57718942 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SendScheduledMessage.kt @@ -32,26 +32,27 @@ import javax.inject.Inject class SendScheduledMessage @Inject constructor( private val context: Context, private val scheduledMessageRepo: ScheduledMessageRepository, + private val deleteScheduledMessagesInteractor: DeleteScheduledMessages, private val sendMessage: SendMessage ) : Interactor() { override fun buildObservable(params: Long): Flowable<*> { return Flowable.just(params) - .mapNotNull(scheduledMessageRepo::getScheduledMessage) - .flatMap { message -> - if (message.sendAsGroup) { - listOf(message) - } else { - message.recipients.map { recipient -> message.copy(recipients = RealmList(recipient)) } - }.toFlowable() - } - .map { message -> - val threadId = TelephonyCompat.getOrCreateThreadId(context, message.recipients) - val attachments = message.attachments.mapNotNull(Uri::parse).map { Attachment(context, it) } - SendMessage.Params(message.subId, threadId, message.recipients, message.body, attachments) - } - .flatMap(sendMessage::buildObservable) - .doOnNext { scheduledMessageRepo.deleteScheduledMessage(params) } + .mapNotNull(scheduledMessageRepo::getScheduledMessage) + .flatMap { message -> + if (message.sendAsGroup) { + listOf(message) + } else { + message.recipients.map { recipient -> message.copy(recipients = RealmList(recipient)) } + }.toFlowable() + } + .map { message -> + val threadId = TelephonyCompat.getOrCreateThreadId(context, message.recipients) + val attachments = message.attachments.mapNotNull(Uri::parse).map { Attachment(context, it) } + SendMessage.Params(message.subId, threadId, message.recipients, message.body, attachments) + } + .flatMap(sendMessage::buildObservable) + .doOnNext { deleteScheduledMessagesInteractor.execute(listOf(params)) } } } diff --git a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt index 257a1ba4..c417f421 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/Attachment.kt @@ -23,11 +23,17 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build -import android.provider.ContactsContract -import android.provider.OpenableColumns -import android.webkit.MimeTypeMap import androidx.core.net.toFile import androidx.core.view.inputmethod.InputContentInfoCompat +import dev.octoshrimpy.quik.extensions.contactToVCard +import dev.octoshrimpy.quik.extensions.getName +import dev.octoshrimpy.quik.extensions.getResourceBytes +import dev.octoshrimpy.quik.extensions.getSize +import dev.octoshrimpy.quik.extensions.getType +import dev.octoshrimpy.quik.extensions.isAudio +import dev.octoshrimpy.quik.extensions.isContact +import dev.octoshrimpy.quik.extensions.isImage +import dev.octoshrimpy.quik.extensions.isVCard @SuppressLint("Range") @@ -43,106 +49,35 @@ class Attachment ( uri = inputContent?.contentUri ?: uri // if constructed with a uri to a contact, convert uri to associated vcard uri - if (context.contentResolver.getType(uri)?.lowercase() == - ContactsContract.Contacts.CONTENT_ITEM_TYPE - ) { - uri = try { - Uri.withAppendedPath( - ContactsContract.Contacts.CONTENT_VCARD_URI, - context.contentResolver.query( - uri, null, null, null, null - )?.use { - it.moveToFirst() - it.getString(it.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY)) ?: "" - } - ) - } catch (e: Exception) { - Uri.EMPTY - } - } + if (uri.isContact(context)) + uri = uri.contactToVCard(context) } - fun getType(context: Context): String { - var retVal: String? = null - when (uri.scheme) { - ContentResolver.SCHEME_CONTENT -> retVal = context.contentResolver.getType(uri) - ContentResolver.SCHEME_FILE -> retVal = - MimeTypeMap.getSingleton().getMimeTypeFromExtension(uri.toFile().extension) - } - return retVal ?: "application/octect-stream" - } + fun isVCard(context: Context): Boolean = uri.isVCard(context) - fun getName(context: Context): String { - var retVal: String? = null - try { - when (uri.scheme) { - ContentResolver.SCHEME_CONTENT -> { - context.contentResolver.query( - uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null - )?.use { - it.moveToFirst() - retVal = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - } - } + fun isAudio(context: Context): Boolean = uri.isAudio(context) - ContentResolver.SCHEME_FILE -> retVal = uri.toFile().name - } - } catch (e: Exception) { /* nothing */ - } + fun isImage(context: Context): Boolean = uri.isImage(context) - return retVal ?: "unknown" - } + fun getType(context: Context): String = uri.getType(context) - fun isVCard(context: Context): Boolean { - return (getType(context) == "text/x-vcard") - } + fun getName(context: Context): String = uri.getName(context) ?: "unknown" + + fun getSize(context: Context): Long = uri.getSize(context) fun hasDisplayableImage(context: Context): Boolean { val mimeType = getType(context) return (mimeType.startsWith("image/") || mimeType.startsWith("video/")) } - fun isAudio(context: Context): Boolean { - return (getType(context).startsWith("audio/")) - } - - fun isImage(context: Context): Boolean { - return (getType(context).startsWith("image/")) - } - - fun getSize(context: Context): Long { - var retVal: Long? = null - try { - when (uri.scheme) { - ContentResolver.SCHEME_CONTENT -> { - context.contentResolver.query( - uri, arrayOf(OpenableColumns.SIZE), null, null, null - )?.use { - it.moveToFirst() - retVal = it.getLong(it.getColumnIndex(OpenableColumns.SIZE)) - } - } - - ContentResolver.SCHEME_FILE -> retVal = uri.toFile().length() - } - } catch (e: Exception) { /* nothing */ - } - - return retVal ?: -1 - } - fun getResourceBytes(context: Context): ByteArray { + // cache resource bytes by loading first time only if (resourceBytes != null) return resourceBytes!! - try { - context.contentResolver.openInputStream(uri)?.use { - resourceBytes = it.readBytes() - } - } catch (e: Exception) { - } + resourceBytes = uri.getResourceBytes(context) - return resourceBytes ?: ByteArray(0) + return resourceBytes!! } fun releaseResourceBytes() { diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt index e4150aa9..e210bd57 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt @@ -33,7 +33,9 @@ interface ScheduledMessageRepository { sendAsGroup: Boolean, body: String, attachments: List - ) + ): ScheduledMessage + + fun updateScheduledMessage(scheduledMessage: ScheduledMessage) /** * Returns all of the scheduled messages, sorted chronologically @@ -53,4 +55,7 @@ interface ScheduledMessageRepository { // delete multiple scheduled messages by id list fun deleteScheduledMessages(ids: List) + // get a list of all scheduled message ids (in scheduled date order) + fun getAllScheduledMessageIdsSnapshot(): List + } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt index 5ba9645a..c7f67db1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt @@ -25,6 +25,7 @@ import android.content.BroadcastReceiver import androidx.core.provider.FontRequest import androidx.emoji.text.EmojiCompat import androidx.emoji.text.FontRequestEmojiCompatConfig +import com.moez.QKSMS.manager.HousekeepingWorkManager import com.moez.QKSMS.manager.SpeakManager import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.util.CrashlyticsTree @@ -114,6 +115,9 @@ class QKApplication : Application(), HasActivityInjector, HasBroadcastReceiverIn RxDogTag.builder() .configureWith(AutoDisposeConfigurer::configure) .install() + + // register, or re-register, once-a-day-housekeeping work manager + HousekeepingWorkManager.register(applicationContext) } override fun activityInjector(): AndroidInjector { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt index 35a69f3f..e342722d 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeAttachmentAdapter.kt @@ -19,25 +19,24 @@ package dev.octoshrimpy.quik.feature.compose import android.content.Context -import android.content.Intent -import android.media.MediaMetadataRetriever -import android.provider.MediaStore.MATCH_DEFAULT import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import com.bumptech.glide.Glide +import com.moez.QKSMS.feature.extensions.LoadBestIconIntoImageView +import com.moez.QKSMS.feature.extensions.loadBestIconIntoImageView import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.base.QkAdapter import dev.octoshrimpy.quik.common.base.QkViewHolder import dev.octoshrimpy.quik.common.util.extensions.getDisplayName -import dev.octoshrimpy.quik.extensions.resourceExists +import dev.octoshrimpy.quik.extensions.getName import dev.octoshrimpy.quik.model.Attachment import ezvcard.Ezvcard import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject -import kotlinx.android.synthetic.main.attachment_file_list_item.* import kotlinx.android.synthetic.main.attachment_contact_list_item.* +import kotlinx.android.synthetic.main.scheduled_message_image_list_item.fileName +import kotlinx.android.synthetic.main.scheduled_message_image_list_item.thumbnail import javax.inject.Inject @@ -90,69 +89,21 @@ class ComposeAttachmentAdapter @Inject constructor( return } - holder.fileName.visibility = View.GONE - - // if attachment uri is missing - if (!attachment.uri.resourceExists(context)) { - holder.thumbnail.setImageResource(android.R.drawable.ic_delete) - holder.fileName.text = context.getString(R.string.attachment_missing) - holder.fileName.visibility = View.VISIBLE - return - } - - val uri = attachment.uri - val mimeType = attachment.getType(context) - - // if attachment mime type is image/* or video/*, use image/frame - if (attachment.hasDisplayableImage(context)) { - Glide - .with(context) - .load(uri) - .into(holder.thumbnail) - return - } - - // if audio mime type, try and use embedded image if one exists - if (attachment.isAudio(context)) { - val metaDataRetriever = MediaMetadataRetriever() - metaDataRetriever.setDataSource(context, uri) - val embeddedPicture = metaDataRetriever.embeddedPicture - if (embeddedPicture != null) { - Glide - .with(context) - .load(embeddedPicture) - .into(holder.thumbnail) - return + // set best image and text to use for icon + when (attachment.uri.loadBestIconIntoImageView(context, holder.thumbnail)) { + LoadBestIconIntoImageView.Missing -> { + holder.fileName.text = context.getString(R.string.attachment_missing) + holder.fileName.visibility = View.VISIBLE } + LoadBestIconIntoImageView.ActivityIcon, + LoadBestIconIntoImageView.DefaultAudioIcon, + LoadBestIconIntoImageView.GenericIcon -> { + // generic style icon used, also show name + holder.fileName.text = attachment.uri.getName(context) + holder.fileName.visibility = View.VISIBLE + } + else -> holder.fileName.visibility = View.GONE } - - // try and use icon from default app for type - val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, mimeType) - val appIcon = context - .packageManager - .resolveActivity(intent, MATCH_DEFAULT) - ?.loadIcon(context.packageManager) - if (appIcon != null) - Glide - .with(context) - .load(appIcon) - .into(holder.thumbnail) - else if (mimeType?.startsWith("audio/") == true) - // else, if audio, use default local audio icon - Glide - .with(context) - .load(R.drawable.ic_round_volume_up_24) - .into(holder.thumbnail) - else - // else, use default attachment icon - Glide - .with(context) - .load(R.drawable.ic_attachment_black_24dp) - .into(holder.thumbnail) - - // else, show file name - holder.fileName.text = attachment.getName(context) - holder.fileName.visibility = View.VISIBLE } override fun getItemViewType(position: Int) = when (getItem(position).isVCard(context)) { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt index daa4ca56..c6179e33 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt @@ -1111,20 +1111,6 @@ class ComposeViewModel @Inject constructor( ) } } - - // when activity changes visibility, delete old recording cache files in background thread - // generally there won't be any, but under some circumstances some can be left behind - view.activityVisibleIntent - .subscribeOn(Schedulers.io()) - .autoDisposable(view.scope()) - .subscribe { - context.cacheDir.listFiles()?.forEach { - if (it.isFile && - it.name.startsWith(AUDIO_FILE_PREFIX) && - it.name.endsWith(MediaRecorderManager.AUDIO_FILE_SUFFIX)) - safeDeleteLocalFile(it.toUri()) - } - } } private fun safeDeleteLocalFile(uri: Uri): Boolean { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/extensions/UriExtensions.kt b/presentation/src/main/java/com/moez/QKSMS/feature/extensions/UriExtensions.kt new file mode 100644 index 00000000..0575308c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/extensions/UriExtensions.kt @@ -0,0 +1,89 @@ +package com.moez.QKSMS.feature.extensions + +import android.content.Context +import android.graphics.drawable.Drawable +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.widget.ImageView +import androidx.appcompat.content.res.AppCompatResources +import com.bumptech.glide.Glide +import dev.octoshrimpy.quik.R +import dev.octoshrimpy.quik.extensions.getDefaultActivityIconForMimeType +import dev.octoshrimpy.quik.extensions.isAudio +import dev.octoshrimpy.quik.extensions.isImage +import dev.octoshrimpy.quik.extensions.isVideo +import dev.octoshrimpy.quik.extensions.resourceExists + +enum class LoadBestIconIntoImageView { + Missing, + ImageVideoIcon, + EmbeddedIcon, + DefaultAudioIcon, + ActivityIcon, + GenericIcon +} + +fun Uri.loadBestIconIntoImageView(context: Context, imageView: ImageView) + : LoadBestIconIntoImageView { + + // if resource is missing, use missing icon + if (!resourceExists(context)) { + Glide + .with(context) + .load(android.R.drawable.ic_delete) + .into(imageView) + return LoadBestIconIntoImageView.Missing + } + + // if attachment is image or video use image or video frame for icon + if (isImage(context) || isVideo(context)) { + Glide + .with(context) + .load(this) + .into(imageView) + return LoadBestIconIntoImageView.ImageVideoIcon + } + + var appIcon: Drawable? = null + + var retVal = LoadBestIconIntoImageView.EmbeddedIcon + + // if audio mime type, try and use embedded image (ie. id3 cover art) if one exists + if (isAudio(context)) { + MediaMetadataRetriever().apply { + setDataSource(context, this@loadBestIconIntoImageView) + val embeddedPicture = embeddedPicture + if (embeddedPicture != null) { + Glide + .with(context) + .load(embeddedPicture) + .into(imageView) + return retVal + } + } + + // else, use applications built-in audio icon + appIcon = AppCompatResources.getDrawable(context, R.drawable.ic_round_volume_up_24) + retVal = LoadBestIconIntoImageView.DefaultAudioIcon + } + + // not audio, try and get icon from default activity for type + if (appIcon == null) { + appIcon = getDefaultActivityIconForMimeType(context) + retVal = LoadBestIconIntoImageView.ActivityIcon + } + + // else, use default local generic attachment icon + if (appIcon == null) { + appIcon = AppCompatResources.getDrawable(context, R.drawable.ic_attachment_black_24dp) + retVal = LoadBestIconIntoImageView.GenericIcon + } + + // load icon + Glide + .with(context) + .load(appIcon) + .into(imageView) + + return retVal +} \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt index 2b826dff..e2a17636 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt @@ -19,19 +19,16 @@ package dev.octoshrimpy.quik.feature.scheduled import android.content.Context -import android.content.Intent -import android.media.MediaMetadataRetriever import android.net.Uri -import android.provider.MediaStore.MATCH_DEFAULT -import android.provider.OpenableColumns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.bumptech.glide.Glide +import com.moez.QKSMS.feature.extensions.LoadBestIconIntoImageView +import com.moez.QKSMS.feature.extensions.loadBestIconIntoImageView import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.base.QkAdapter import dev.octoshrimpy.quik.common.base.QkViewHolder -import dev.octoshrimpy.quik.extensions.resourceExists +import dev.octoshrimpy.quik.extensions.getName import kotlinx.android.synthetic.main.scheduled_message_image_list_item.fileName import kotlinx.android.synthetic.main.scheduled_message_image_list_item.thumbnail import javax.inject.Inject @@ -52,78 +49,20 @@ class ScheduledMessageAttachmentAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val uri = getItem(position) - // if uri target doesn't exist anymore - if (!uri.resourceExists(context)) { - holder.thumbnail.setImageResource(android.R.drawable.ic_delete) - holder.fileName.text = context.getString(R.string.attachment_missing) - holder.fileName.visibility = View.VISIBLE - return - } - - val mimeType = context.contentResolver.getType(uri) - - // if attachment mime type is image/* or video/*, use image/video-frame - if ((mimeType?.startsWith("image/") == true) || - (mimeType?.startsWith("video/") == true) - ) { - Glide - .with(context) - .load(uri) - .into(holder.thumbnail) - return - } - - // if audio mime type, try and use embedded image if one exists - if (mimeType?.startsWith("audio/") == true) { - val metaDataRetriever = MediaMetadataRetriever() - metaDataRetriever.setDataSource(context, uri) - val embeddedPicture = metaDataRetriever.embeddedPicture - if (embeddedPicture != null) { - Glide - .with(context) - .load(embeddedPicture) - .into(holder.thumbnail) - return - } - } - - // try and use icon from default app for type - val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, mimeType) - val appIcon = context - .packageManager - .resolveActivity(intent, MATCH_DEFAULT) - ?.loadIcon(context.packageManager) - if (appIcon != null) - Glide - .with(context) - .load(appIcon) - .into(holder.thumbnail) - else if (mimeType?.startsWith("audio/") == true) - // else, if audio, use default local audio icon - Glide - .with(context) - .load(R.drawable.ic_round_volume_up_24) - .into(holder.thumbnail) - else - // else, use default attachment icon - Glide - .with(context) - .load(R.drawable.ic_attachment_black_24dp) - .into(holder.thumbnail) - - // else, show file name - val cursor = context.contentResolver.query( - uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null - ) - cursor?.use { - try { - cursor.moveToFirst() - holder.fileName.text = cursor.getString( - cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) ?: 0 - ) + // set best image and text to use for icon + when (getItem(position).loadBestIconIntoImageView(context, holder.thumbnail)) { + LoadBestIconIntoImageView.Missing -> { + holder.fileName.text = context.getString(R.string.attachment_missing) holder.fileName.visibility = View.VISIBLE - } catch (e: Exception) { } + LoadBestIconIntoImageView.ActivityIcon, + LoadBestIconIntoImageView.DefaultAudioIcon, + LoadBestIconIntoImageView.GenericIcon -> { + // generic style icon used, also show name + holder.fileName.text = uri.getName(context) + holder.fileName.visibility = View.VISIBLE + } + else -> holder.fileName.visibility = View.GONE } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt index e2449208..05e1cab0 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt @@ -10,6 +10,7 @@ import dev.octoshrimpy.quik.repository.ScheduledMessageRepository import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable import dev.octoshrimpy.quik.common.util.ClipboardUtils +import dev.octoshrimpy.quik.interactor.DeleteScheduledMessages import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom @@ -22,6 +23,7 @@ class ScheduledViewModel @Inject constructor( private val navigator: Navigator, private val scheduledMessageRepo: ScheduledMessageRepository, private val sendScheduledMessageInteractor: SendScheduledMessage, + private val deleteScheduledMessagesInteractor: DeleteScheduledMessages, ) : QkViewModel(ScheduledState( scheduledMessages = scheduledMessageRepo.getScheduledMessages() )) { @@ -87,7 +89,7 @@ class ScheduledViewModel @Inject constructor( .subscribeOn(Schedulers.io()) .autoDisposable(view.scope()) .subscribe { - scheduledMessageRepo.deleteScheduledMessages(it) + deleteScheduledMessagesInteractor.execute(it) view.clearSelection() }