ntfy-android/app/src/main/java/io/heckel/ntfy/util/Util.kt

493 lines
17 KiB
Kotlin
Raw Normal View History

2021-11-28 10:18:09 +13:00
package io.heckel.ntfy.util
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.content.ClipData
import android.content.ClipboardManager
2022-02-12 09:55:08 +13:00
import android.content.ContentResolver
import android.content.Context
2022-01-20 16:57:07 +13:00
import android.content.res.Configuration
import android.content.res.Configuration.UI_MODE_NIGHT_YES
2022-02-13 09:26:18 +13:00
import android.content.res.Resources
2022-05-09 14:57:52 +12:00
import android.graphics.Bitmap
import android.graphics.BitmapFactory
2022-04-30 03:03:02 +12:00
import android.graphics.drawable.RippleDrawable
import android.net.Uri
2022-01-19 10:49:00 +13:00
import android.os.Build
import android.os.PowerManager
import android.provider.OpenableColumns
2022-02-16 14:29:32 +13:00
import android.text.Editable
import android.text.TextWatcher
import android.util.Base64
2022-02-13 09:26:18 +13:00
import android.util.TypedValue
import android.view.View
2021-11-28 10:18:09 +13:00
import android.view.Window
2022-02-13 09:26:18 +13:00
import android.widget.ImageView
import android.widget.Toast
2022-01-20 16:57:07 +13:00
import androidx.appcompat.app.AppCompatDelegate
2022-02-12 14:34:08 +13:00
import io.heckel.ntfy.R
import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
2022-04-30 03:03:02 +12:00
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
2022-02-12 09:55:08 +13:00
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
2021-11-28 10:18:09 +13:00
import java.text.DateFormat
import java.text.StringCharacterIterator
2021-11-28 10:18:09 +13:00
import java.util.*
2022-01-12 13:37:34 +13:00
import kotlin.math.abs
2022-06-20 06:45:01 +12:00
import kotlin.math.absoluteValue
2021-11-28 10:18:09 +13:00
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
2021-12-30 08:33:17 +13:00
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
2021-11-28 10:18:09 +13:00
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
2022-01-16 07:31:34 +13:00
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
2022-01-28 13:57:43 +13:00
fun topicUrlAuth(baseUrl: String, topic: String) = "${topicUrl(baseUrl, topic)}/auth"
fun topicUrlJsonPoll(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?poll=1&since=$since"
fun topicShortUrl(baseUrl: String, topic: String) = shortUrl(topicUrl(baseUrl, topic))
fun displayName(subscription: Subscription) : String {
return subscription.displayName ?: topicShortUrl(subscription.baseUrl, subscription.topic)
}
fun shortUrl(url: String) = url
.replace("http://", "")
.replace("https://", "")
2021-11-28 10:18:09 +13:00
2022-02-13 17:02:42 +13:00
fun splitTopicUrl(topicUrl: String): Pair<String, String> {
if (topicUrl.lastIndexOf("/") == -1) throw Exception("Invalid argument $topicUrl")
return Pair(topicUrl.substringBeforeLast("/"), topicUrl.substringAfterLast("/"))
}
2022-03-15 10:10:44 +13:00
fun maybeSplitTopicUrl(topicUrl: String): Pair<String, String>? {
return try {
splitTopicUrl(topicUrl)
} catch (_: Exception) {
null
}
}
2022-02-13 09:26:18 +13:00
fun validTopic(topic: String): Boolean {
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side!
}
fun validUrl(url: String): Boolean {
2022-04-14 12:09:56 +12:00
return "^https?://\\S+".toRegex().matches(url)
2022-02-13 09:26:18 +13:00
}
2021-11-28 10:18:09 +13:00
fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
2021-11-28 10:18:09 +13:00
}
fun toPriority(priority: Int?): Int {
if (priority != null && (1..5).contains(priority)) return priority
else return 3
}
fun toPriorityString(context: Context, priority: Int): String {
return when (priority) {
1 -> context.getString(R.string.settings_notifications_priority_min)
2 -> context.getString(R.string.settings_notifications_priority_low)
3 -> context.getString(R.string.settings_notifications_priority_default)
4 -> context.getString(R.string.settings_notifications_priority_high)
5 -> context.getString(R.string.settings_notifications_priority_max)
else -> context.getString(R.string.settings_notifications_priority_default)
}
}
2021-11-28 10:18:09 +13:00
fun joinTags(tags: List<String>?): String {
return tags?.joinToString(",") ?: ""
}
fun joinTagsMap(tags: List<String>?): String {
2021-12-14 17:38:23 +13:00
return tags?.mapIndexed { i, tag -> "${i+1}=${tag}" }?.joinToString(",") ?: ""
2021-11-28 10:18:09 +13:00
}
2021-11-29 13:28:58 +13:00
fun splitTags(tags: String?): List<String> {
return if (tags == null || tags == "") {
emptyList()
} else {
tags.split(",")
}
2021-11-28 10:18:09 +13:00
}
2021-11-29 13:28:58 +13:00
fun toEmojis(tags: List<String>): List<String> {
return tags.mapNotNull { tag -> toEmoji(tag) }
}
fun toEmoji(tag: String): String? {
return EmojiManager.getForAlias(tag)?.unicode
2021-11-28 10:18:09 +13:00
}
2021-11-29 13:28:58 +13:00
fun unmatchedTags(tags: List<String>): List<String> {
return tags.filter { tag -> toEmoji(tag) == null }
}
2021-11-28 10:18:09 +13:00
/**
* Prepend tags/emojis to message, but only if there is a non-empty title.
* Otherwise, the tags will be prepended to the title.
2021-11-28 10:18:09 +13:00
*/
fun formatMessage(notification: Notification): String {
return if (notification.title != "") {
decodeMessage(notification)
2021-11-28 10:18:09 +13:00
} else {
2021-11-29 13:28:58 +13:00
val emojis = toEmojis(splitTags(notification.tags))
2021-11-28 10:18:09 +13:00
if (emojis.isEmpty()) {
decodeMessage(notification)
} else {
emojis.joinToString("") + " " + decodeMessage(notification)
}
}
}
fun decodeMessage(notification: Notification): String {
return try {
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
String(Base64.decode(notification.message, Base64.DEFAULT))
} else {
2021-11-28 10:18:09 +13:00
notification.message
}
} catch (e: IllegalArgumentException) {
notification.message + "(invalid base64)"
}
}
fun decodeBytesMessage(notification: Notification): ByteArray {
return try {
if (notification.encoding == MESSAGE_ENCODING_BASE64) {
Base64.decode(notification.message, Base64.DEFAULT)
2021-11-28 10:18:09 +13:00
} else {
notification.message.toByteArray()
2021-11-28 10:18:09 +13:00
}
} catch (e: IllegalArgumentException) {
notification.message.toByteArray()
2021-11-28 10:18:09 +13:00
}
}
/**
* See above; prepend emojis to title if the title is non-empty.
* Otherwise, they are prepended to the message.
*/
fun formatTitle(subscription: Subscription, notification: Notification): String {
return if (notification.title != "") {
formatTitle(notification)
} else {
displayName(subscription)
2021-11-28 10:18:09 +13:00
}
}
fun formatTitle(notification: Notification): String {
2021-11-29 13:28:58 +13:00
val emojis = toEmojis(splitTags(notification.tags))
2021-11-28 10:18:09 +13:00
return if (emojis.isEmpty()) {
notification.title
} else {
emojis.joinToString("") + " " + notification.title
}
}
fun formatActionLabel(action: Action): String {
return when (action.progress) {
ACTION_PROGRESS_ONGOING -> action.label + ""
ACTION_PROGRESS_SUCCESS -> action.label + " ✔️"
ACTION_PROGRESS_FAILED -> action.label + " ❌️"
else -> action.label
}
}
fun maybeAppendActionErrors(message: String, notification: Notification): String {
val actionErrors = notification.actions
.orEmpty()
.mapNotNull { action -> action.error }
.joinToString("\n")
if (actionErrors.isEmpty()) {
return message
} else {
return "${message}\n\n${actionErrors}"
}
}
// Checks in the most horrible way if a content URI exists; I couldn't find a better way
fun fileExists(context: Context, contentUri: String?): Boolean {
return try {
2022-02-12 14:34:08 +13:00
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
true
} catch (_: Exception) {
false
}
}
// Queries the filename of a content URI
2022-02-12 14:34:08 +13:00
fun fileName(context: Context, contentUri: String?, fallbackName: String): String {
return try {
2022-02-12 14:34:08 +13:00
val info = fileStat(context, Uri.parse(contentUri))
info.filename
} catch (_: Exception) {
fallbackName
}
}
2022-02-12 14:34:08 +13:00
fun fileStat(context: Context, contentUri: Uri?): FileInfo {
if (contentUri == null) {
throw FileNotFoundException("URI is null")
}
val resolver = context.applicationContext.contentResolver
2022-02-12 14:34:08 +13:00
val cursor = resolver.query(contentUri, null, null, null, null) ?: throw Exception("Query returned null")
return cursor.use { c ->
val nameIndex = c.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
2022-02-12 14:34:08 +13:00
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
if (!c.moveToFirst()) {
throw FileNotFoundException("Not found: $contentUri")
}
val size = c.getLong(sizeIndex)
if (size == 0L) {
// Content provider URIs (e.g. content://io.heckel.ntfy.provider/cache_files/DQ4o7DitZAmw) return an entry, even
// when they do not exist, but with an empty size. This is a practical/fast way to weed out non-existing files.
throw FileNotFoundException("Not found or empty: $contentUri")
}
2022-02-12 14:34:08 +13:00
FileInfo(
filename = c.getString(nameIndex),
size = c.getLong(sizeIndex)
)
}
}
fun maybeFileStat(context: Context, contentUri: String?): FileInfo? {
return try {
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
} catch (_: Exception) {
null
}
}
2022-02-12 14:34:08 +13:00
data class FileInfo(
val filename: String,
val size: Long,
)
2021-11-28 10:18:09 +13:00
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor)
statusBarColorAnimation.addUpdateListener { animator ->
val color = animator.animatedValue as Int
window.statusBarColor = color
}
statusBarColorAnimation.start()
}
2022-01-01 03:30:49 +13:00
// Generates a (cryptographically secure) random string of a certain length
fun randomString(len: Int): String {
val random = SecureRandom()
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray()
return (1..len).map { chars[random.nextInt(chars.size)] }.joinToString("")
}
2022-06-20 06:45:01 +12:00
// Generates a random, positive subscription ID between 0-10M. This ensures that it doesn't have issues
// when exported to JSON. It uses SecureRandom, because Random causes issues in the emulator (generating the
// same value again and again), sometimes.
fun randomSubscriptionId(): Long {
return SecureRandom().nextLong().absoluteValue % 100_000_000
}
// Allows letting multiple variables at once, see https://stackoverflow.com/a/35522422/1440785
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null
}
2022-01-12 13:37:34 +13:00
fun formatBytes(bytes: Long, decimals: Int = 1): String {
val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes)
if (absB < 1024) {
return "$bytes B"
}
var value = absB
val ci = StringCharacterIterator("KMGTPE")
var i = 40
while (i >= 0 && absB > 0xfffccccccccccccL shr i) {
value = value shr 10
ci.next()
i -= 10
}
value *= java.lang.Long.signum(bytes).toLong()
2022-01-12 13:37:34 +13:00
return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current())
}
2022-01-09 16:17:41 +13:00
2022-02-12 14:34:08 +13:00
fun mimeTypeToIconResource(mimeType: String?): Int {
return if (mimeType?.startsWith("image/") == true) {
R.drawable.ic_file_image_red_24dp
} else if (mimeType?.startsWith("video/") == true) {
R.drawable.ic_file_video_orange_24dp
} else if (mimeType?.startsWith("audio/") == true) {
R.drawable.ic_file_audio_purple_24dp
} else if (mimeType == "application/vnd.android.package-archive") {
R.drawable.ic_file_app_gray_24dp
} else {
R.drawable.ic_file_document_blue_24dp
}
}
2022-01-09 16:17:41 +13:00
fun supportedImage(mimeType: String?): Boolean {
return listOf("image/jpeg", "image/png").contains(mimeType)
}
2022-01-19 10:49:00 +13:00
// Check if battery optimization is enabled, see https://stackoverflow.com/a/49098293/1440785
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val powerManager = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val appName = context.applicationContext.packageName
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return powerManager.isIgnoringBatteryOptimizations(appName)
}
return true
}
2022-01-20 16:57:07 +13:00
// Returns true if dark mode is on, see https://stackoverflow.com/a/60761189/1440785
fun Context.systemDarkThemeOn(): Boolean {
2022-01-20 16:57:07 +13:00
return resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES
}
fun isDarkThemeOn(context: Context): Boolean {
val darkMode = Repository.getInstance(context).getDarkMode()
2022-01-20 16:57:07 +13:00
if (darkMode == AppCompatDelegate.MODE_NIGHT_YES) {
return true
}
if (darkMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && context.systemDarkThemeOn()) {
2022-01-20 16:57:07 +13:00
return true
}
return false
}
2022-02-12 09:55:08 +13:00
// https://cketti.de/2020/05/23/content-uris-and-okhttp/
class ContentUriRequestBody(
2022-02-13 07:51:41 +13:00
private val resolver: ContentResolver,
private val uri: Uri,
private val size: Long
2022-02-12 09:55:08 +13:00
) : RequestBody() {
2022-02-13 07:51:41 +13:00
override fun contentLength(): Long {
return size
}
2022-02-12 09:55:08 +13:00
override fun contentType(): MediaType? {
2022-02-13 07:51:41 +13:00
val contentType = resolver.getType(uri)
2022-02-12 09:55:08 +13:00
return contentType?.toMediaTypeOrNull()
}
override fun writeTo(sink: BufferedSink) {
2022-02-13 07:51:41 +13:00
val inputStream = resolver.openInputStream(uri) ?: throw IOException("Couldn't open content URI for reading")
2022-02-12 09:55:08 +13:00
inputStream.source().use { source ->
sink.writeAll(source)
}
}
}
2022-02-13 09:26:18 +13:00
// Hack: Make end icon for drop down smaller, see https://stackoverflow.com/a/57098715/1440785
fun View.makeEndIconSmaller(resources: Resources) {
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
endIconImageView.minimumHeight = dimension.toInt()
endIconImageView.minimumWidth = dimension.toInt()
requestLayout()
}
2022-02-16 14:29:32 +13:00
2022-04-30 03:03:02 +12:00
// Shows the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
fun View.showRipple() {
if (background is RippleDrawable) {
background.state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)
}
}
// Hides the ripple effect on the view, if it is ripple-able, see https://stackoverflow.com/a/56314062/1440785
fun View.hideRipple() {
if (background is RippleDrawable) {
background.state = intArrayOf()
}
}
// Toggles the ripple effect on the view, if it is ripple-able
fun View.ripple(scope: CoroutineScope) {
showRipple()
scope.launch(Dispatchers.Main) {
delay(200)
hideRipple()
}
}
2022-05-09 14:57:52 +12:00
fun Uri.readBitmapFromUri(context: Context): Bitmap {
val resolver = context.applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(this)
2022-11-28 06:38:07 +13:00
val bitmap = BitmapFactory.decodeStream(bitmapStream)
if (bitmap.byteCount > 100 * 1024 * 1024) {
// If the Bitmap is too large to be rendered (100 MB), it will throw a RuntimeException downstream.
// This workaround throws a catchable exception instead. See issue #474. From https://stackoverflow.com/a/53334563/1440785
throw Exception("Bitmap too large to draw on Canvas (${bitmap.byteCount} bytes)")
}
return bitmap
2022-05-09 14:57:52 +12:00
}
fun String.readBitmapFromUri(context: Context): Bitmap {
return Uri.parse(this).readBitmapFromUri(context)
}
fun String.readBitmapFromUriOrNull(context: Context): Bitmap? {
return try {
this.readBitmapFromUri(context)
} catch (_: Exception) {
null
}
}
2022-02-16 14:29:32 +13:00
// TextWatcher that only implements the afterTextChanged method
class AfterChangedTextWatcher(val afterTextChangedFn: (s: Editable?) -> Unit) : TextWatcher {
override fun afterTextChanged(s: Editable?) {
afterTextChangedFn(s)
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// Nothing
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Nothing
}
}
2022-03-15 10:10:44 +13:00
fun ensureSafeNewFile(dir: File, name: String): File {
val safeName = name.replace("[^-_.()\\w]+".toRegex(), "_");
val file = File(dir, safeName)
if (!file.exists()) {
return file
}
(1..1000).forEach { i ->
val newFile = File(dir, if (file.extension == "") {
"${file.nameWithoutExtension} ($i)"
} else {
"${file.nameWithoutExtension} ($i).${file.extension}"
})
if (!newFile.exists()) {
return newFile
}
}
throw Exception("Cannot find safe file")
}
fun copyToClipboard(context: Context, notification: Notification) {
val message = decodeMessage(notification)
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
2022-11-13 10:02:25 +13:00
val clip = ClipData.newPlainText("notification message", message)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, context.getString(R.string.detail_copied_to_clipboard_message), Toast.LENGTH_LONG)
.show()
}
fun String.sha256(): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(this.toByteArray())
return digest.fold("") { str, it -> str + "%02x".format(it) }
}