limit bubble display of text to 5000 chars.

add instruction status message when bubble char limit exceeded.
add share text feature.
make copy and share text menu options only available when there is text in selected messages.
new FileProvider to share messages text files out of app's cache dir.
add message subject to copy messages text feature.
a lot of refactoring of MessagesAdapter class.
add new FileUtils class.
convert file logging to use new FileUtils class.
change MediaRecorder to use new FileUtils class.
housekeeping worker now cleans messages text cache files.
housekeeping worker now checks cache files older than 2 hours before deleting.
This commit is contained in:
gavine99
2025-03-26 17:28:34 +11:00
parent 669932ef0e
commit 8356008b88
18 changed files with 707 additions and 340 deletions
@@ -1,26 +0,0 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package dev.octoshrimpy.quik.extensions
import java.text.Normalizer
/**
* Strip the accents from a string
*/
fun CharSequence.removeAccents(): String = Normalizer.normalize(this, Normalizer.Form.NFKD).replace(Regex("\\p{M}"), "")
@@ -23,8 +23,8 @@ import android.media.AudioDeviceInfo
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import androidx.core.net.toUri
import java.io.File
import androidx.core.net.toFile
import dev.octoshrimpy.quik.util.FileUtils
import java.util.UUID
object MediaRecorderManager : MediaRecorder() {
@@ -63,10 +63,16 @@ object MediaRecorderManager : MediaRecorder() {
fun startRecording(context: Context, preferredAudioDevice: AudioDeviceInfo? = null): Uri {
return try {
val file = File(
context.cacheDir,
AUDIO_FILE_PREFIX + UUID.randomUUID() + AUDIO_FILE_SUFFIX
val (newUri, e) = FileUtils.create(
FileUtils.Companion.Location.Cache,
context,
"$AUDIO_FILE_PREFIX${UUID.randomUUID()}$AUDIO_FILE_SUFFIX",
""
)
if (e is Exception)
throw e
uri = newUri
// ensure stopped before using again
stopRecording()
@@ -76,13 +82,12 @@ object MediaRecorderManager : MediaRecorder() {
setOutputFormat(OutputFormat.THREE_GPP)
setAudioEncoder(AudioEncoder.AMR_WB)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
preferredDevice = preferredAudioDevice
}
recordingState = RecordingState.DataSourceConfigured
setOutputFile(file.path)
setOutputFile(uri.toFile().path)
prepare()
recordingState = RecordingState.Prepared
@@ -90,8 +95,6 @@ object MediaRecorderManager : MediaRecorder() {
start()
recordingState = RecordingState.Recording
uri = file.toUri()
uri
}
catch (e: Exception) {
@@ -0,0 +1,7 @@
package com.moez.QKSMS.util
class Constants {
companion object {
const val SAVED_MESSAGE_TEXT_FILE_PREFIX = "QuikSmsText-"
}
}
@@ -0,0 +1,149 @@
package dev.octoshrimpy.quik.util
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.core.content.contentValuesOf
import androidx.core.net.toFile
import dev.octoshrimpy.quik.extensions.isEmpty
import java.io.File
class FileUtils {
companion object {
enum class Location {
Downloads,
ExternalCache,
Cache,
Files,
Obb,
CodeCache,
NoBackup,
}
fun create(
location: Location,
context: Context,
filename: String,
mimeType: String
) =
if (location == Location.Downloads) createDownloadsFile(context, filename, mimeType)
else createLocation(location, context, filename)
private fun createDownloadsFile(
context: Context,
filename: String,
mimeType: String
): Pair<Uri, Exception?> =
try {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // use media store
context.contentResolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValuesOf(
MediaStore.MediaColumns.MIME_TYPE to mimeType,
MediaStore.MediaColumns.RELATIVE_PATH to
Environment.DIRECTORY_DOWNLOADS,
MediaStore.MediaColumns.DISPLAY_NAME to filename,
)
) ?: Uri.EMPTY
} else { // use direct access to 'external' dir
File(Environment.getExternalStorageDirectory(), filename).let {
if (tryOrNull { it.createNewFile() } == true) Uri.fromFile(it)
else Uri.EMPTY
}
}
if (uri.isEmpty())
throw Exception("Opening file returned an empty uri")
Pair(uri, null)
} catch (e: Exception) {
Pair(Uri.EMPTY, e)
}
private fun createLocation(
location: Location,
context: Context,
filename: String
): Pair<Uri, Exception?> =
try {
val uri = File(
when (location) {
Location.ExternalCache -> context.externalCacheDir
Location.Files -> context.filesDir
Location.Obb -> context.obbDir
Location.CodeCache -> context.codeCacheDir
Location.NoBackup -> context.noBackupFilesDir
else -> context.cacheDir
},
filename
).let {
if (tryOrNull { it.createNewFile() } == true) Uri.fromFile(it)
else Uri.EMPTY
}
if (uri.isEmpty())
throw Exception("Opening file returned an empty uri")
Pair(uri, null)
} catch (e: Exception) {
Pair(Uri.EMPTY, e)
}
private fun writeBytes(
context: Context,
uri: Uri,
bytes: ByteArray,
mode: String
): Exception? =
try {
context.contentResolver.openOutputStream(uri, mode)?.use { it.write(bytes) }
null
} catch (e: Exception) {
e
}
fun append(context: Context, uri: Uri, bytes: ByteArray): Exception? =
writeBytes(context, uri, bytes, "wa")
fun write(context: Context, uri: Uri, bytes: ByteArray): Exception? =
writeBytes(context, uri, bytes, "w")
fun truncateAndWrite(context: Context, uri: Uri, bytes: ByteArray): Exception? =
writeBytes(context, uri, bytes, "wt")
fun createAndWrite(
context: Context,
location: Location,
filename: String,
mimeType: String,
bytes: ByteArray
): Pair<Uri, Exception?> =
create(location, context, filename, mimeType).let { (uri, e) ->
if (e is Exception) Pair(Uri.EMPTY, e)
else {
val we = writeBytes(context, uri, bytes, "w")
if (we is Exception) {
deleteFile(uri)
Pair(Uri.EMPTY, we)
}
else
Pair(uri, null)
}
}
fun deleteFile(uri: Uri): Boolean {
return try {
if (uri == Uri.EMPTY) false
else uri.toFile().delete()
}
catch (e: Exception) { false }
}
fun renameTo(fromUri: Uri, toUri: Uri) {
fromUri.toFile().renameTo(toUri.toFile())
}
}
}
@@ -27,6 +27,7 @@ import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.moez.QKSMS.util.Constants
import androidx.work.workDataOf
import com.moez.QKSMS.manager.MediaRecorderManager
import com.moez.QKSMS.manager.MediaRecorderManager.AUDIO_FILE_PREFIX
@@ -42,6 +43,8 @@ class HousekeepingWorker(appContext: Context, workerParams: WorkerParameters)
companion object {
private val WORKER_TAG: String = HousekeepingWorker::class.java.simpleName
private val someHoursAgo = (System.currentTimeMillis() - (2 * 60 * 60 * 1000))
fun register(context: Context) {
// don't check return value because, well, we can't do much about a failure
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
@@ -49,12 +52,12 @@ class HousekeepingWorker(appContext: Context, workerParams: WorkerParameters)
ExistingPeriodicWorkPolicy.UPDATE,
PeriodicWorkRequest.Builder(
HousekeepingWorker::class.java,
24,
TimeUnit.HOURS
1,
TimeUnit.DAYS
)
.setConstraints(
Constraints.Builder()
// idle device constraint kinda guarantees quik won't be in use
// idle device constraint helps 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
@@ -76,7 +79,9 @@ class HousekeepingWorker(appContext: Context, workerParams: WorkerParameters)
override fun doWork(): Result {
removeOrphanedScheduledMessageAttachmentFiles()
removeOrphanedComposeAudioRecording()
removeOrphanedComposeAudioRecordings()
removeSavedMessagesTexts()
return Result.success()
}
@@ -97,15 +102,21 @@ class HousekeepingWorker(appContext: Context, workerParams: WorkerParameters)
?.forEach { it.deleteRecursively() }
}
private fun removeOrphanedComposeAudioRecording() {
private fun removeOrphanedComposeAudioRecordings() =
// 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() }
}
entry.name.startsWith(MediaRecorderManager.AUDIO_FILE_PREFIX) &&
entry.name.endsWith(MediaRecorderManager.AUDIO_FILE_SUFFIX) &&
(entry.lastModified() < someHoursAgo)
}?.forEach { it.delete() } // delete recording file
private fun removeSavedMessagesTexts() =
// find saved message text files in cache dir
applicationContext.cacheDir.listFiles { entry ->
entry.isFile &&
entry.name.startsWith(Constants.SAVED_MESSAGE_TEXT_FILE_PREFIX) &&
(entry.lastModified() < someHoursAgo)
}?.forEach { it.delete() } // delete message text file
}