ntfy-android/app/src/main/java/io/heckel/ntfy/util/Util.kt
2022-02-12 13:51:41 -05:00

274 lines
9.1 KiB
Kotlin

package io.heckel.ntfy.util
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.content.ContentResolver
import android.content.Context
import android.content.res.Configuration
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.OpenableColumns
import android.view.Window
import androidx.appcompat.app.AppCompatDelegate
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.security.SecureRandom
import java.text.DateFormat
import java.text.StringCharacterIterator
import java.util.*
import kotlin.math.abs
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
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 shortUrl(url: String) = url
.replace("http://", "")
.replace("https://", "")
fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
}
fun toPriority(priority: Int?): Int {
if (priority != null && (1..5).contains(priority)) return priority
else return 3
}
fun toPriorityString(priority: Int): String {
return when (priority) {
1 -> "min"
2 -> "low"
3 -> "default"
4 -> "high"
5 -> "max"
else -> "default"
}
}
fun joinTags(tags: List<String>?): String {
return tags?.joinToString(",") ?: ""
}
fun joinTagsMap(tags: List<String>?): String {
return tags?.mapIndexed { i, tag -> "${i+1}=${tag}" }?.joinToString(",") ?: ""
}
fun splitTags(tags: String?): List<String> {
return if (tags == null || tags == "") {
emptyList()
} else {
tags.split(",")
}
}
fun toEmojis(tags: List<String>): List<String> {
return tags.mapNotNull { tag -> toEmoji(tag) }
}
fun toEmoji(tag: String): String? {
return EmojiManager.getForAlias(tag)?.unicode
}
fun unmatchedTags(tags: List<String>): List<String> {
return tags.filter { tag -> toEmoji(tag) == null }
}
/**
* Prepend tags/emojis to message, but only if there is a non-empty title.
* Otherwise the tags will be prepended to the title.
*/
fun formatMessage(notification: Notification): String {
return if (notification.title != "") {
notification.message
} else {
val emojis = toEmojis(splitTags(notification.tags))
if (emojis.isEmpty()) {
notification.message
} else {
emojis.joinToString("") + " " + notification.message
}
}
}
/**
* 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 {
topicShortUrl(subscription.baseUrl, subscription.topic)
}
}
fun formatTitle(notification: Notification): String {
val emojis = toEmojis(splitTags(notification.tags))
return if (emojis.isEmpty()) {
notification.title
} else {
emojis.joinToString("") + " " + notification.title
}
}
// 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 {
fileStat(context, Uri.parse(contentUri)) // Throws if the file does not exist
true
} catch (_: Exception) {
false
}
}
// Queries the filename of a content URI
fun fileName(context: Context, contentUri: String?, fallbackName: String): String {
return try {
val info = fileStat(context, Uri.parse(contentUri))
info.filename
} catch (_: Exception) {
fallbackName
}
}
fun fileStat(context: Context, contentUri: Uri?): FileInfo {
if (contentUri == null) throw Exception("URI is null")
val resolver = context.applicationContext.contentResolver
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)
val sizeIndex = c.getColumnIndexOrThrow(OpenableColumns.SIZE)
c.moveToFirst()
FileInfo(
filename = c.getString(nameIndex),
size = c.getLong(sizeIndex)
)
}
}
data class FileInfo(
val filename: String,
val size: Long,
)
// 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()
}
// 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("")
}
// 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
}
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()
return java.lang.String.format("%.${decimals}f %cB", value / 1024.0, ci.current())
}
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
}
}
fun supportedImage(mimeType: String?): Boolean {
return listOf("image/jpeg", "image/png").contains(mimeType)
}
// 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
}
// Returns true if dark mode is on, see https://stackoverflow.com/a/60761189/1440785
fun Context.systemDarkThemeOn(): Boolean {
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()
if (darkMode == AppCompatDelegate.MODE_NIGHT_YES) {
return true
}
if (darkMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && context.systemDarkThemeOn()) {
return true
}
return false
}
// https://cketti.de/2020/05/23/content-uris-and-okhttp/
class ContentUriRequestBody(
private val resolver: ContentResolver,
private val uri: Uri,
private val size: Long
) : RequestBody() {
override fun contentLength(): Long {
return size
}
override fun contentType(): MediaType? {
val contentType = resolver.getType(uri)
return contentType?.toMediaTypeOrNull()
}
override fun writeTo(sink: BufferedSink) {
val inputStream = resolver.openInputStream(uri) ?: throw IOException("Couldn't open content URI for reading")
inputStream.source().use { source ->
sink.writeAll(source)
}
}
}