Implement priority filtering, rename package, preset URL, update remotes
Some checks failed
Build / Check (push) Has been cancelled

This commit is contained in:
kdusek
2025-11-28 20:06:33 +01:00
parent 547d9fd943
commit afcf93087c
42 changed files with 194 additions and 170 deletions

View File

@@ -0,0 +1,157 @@
package com.github.gotifycustom
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.executeBlocking
import coil.fetch.DrawableResult
import coil.fetch.Fetcher
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.Options
import coil.request.SuccessResult
import com.github.gotifycustom.api.CertUtils
import com.github.gotify.client.model.Application
import java.io.IOException
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import org.tinylog.kotlin.Logger
object CoilInstance {
private var holder: Pair<SSLSettings, ImageLoader>? = null
@Throws(IOException::class)
fun getImageFromUrl(
context: Context,
url: String?,
@DrawableRes placeholder: Int = R.drawable.ic_placeholder
): Bitmap {
val request = ImageRequest.Builder(context).data(url).build()
return when (val result = get(context).executeBlocking(request)) {
is SuccessResult -> result.drawable.toBitmap()
is ErrorResult -> {
Logger.error(
result.throwable
) { "Could not load image ${Utils.redactPassword(url)}" }
AppCompatResources.getDrawable(context, placeholder)!!.toBitmap()
}
}
}
fun getIcon(context: Context, app: Application?): Bitmap {
if (app == null) {
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
val baseUrl = Settings(context).url
return getImageFromUrl(
context,
Utils.resolveAbsoluteUrl("$baseUrl/", app.image),
R.drawable.gotify
)
}
@OptIn(ExperimentalCoilApi::class)
fun evict(context: Context) {
try {
get(context).apply {
diskCache?.clear()
memoryCache?.clear()
}
} catch (e: IOException) {
Logger.error(e, "Problem evicting Coil cache")
}
}
@Synchronized
fun get(context: Context): ImageLoader {
val newSettings = Settings(context).sslSettings()
val copy = holder
if (copy != null && copy.first == newSettings) {
return copy.second
}
return makeImageLoader(context, newSettings).also { holder = it }.second
}
private fun makeImageLoader(
context: Context,
sslSettings: SSLSettings
): Pair<SSLSettings, ImageLoader> {
val builder = OkHttpClient
.Builder()
.addInterceptor(BasicAuthInterceptor())
CertUtils.applySslSettings(builder, sslSettings)
val loader = ImageLoader.Builder(context)
.okHttpClient(builder.build())
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("coil-cache"))
.build()
}
.components {
add(SvgDecoder.Factory())
add(DataDecoderFactory())
}
.build()
return sslSettings to loader
}
}
private class BasicAuthInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
// If there's no username, skip the authentication
if (request.url.username.isNotEmpty()) {
request = request
.newBuilder()
.header(
"Authorization",
Credentials.basic(request.url.username, request.url.password)
)
.build()
}
return chain.proceed(request)
}
}
class DataDecoderFactory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
if (!data.scheme.equals("data", ignoreCase = true)) {
return null
}
val uri = data.toString()
val imageDataBytes = data.toString().substring(uri.indexOf(",") + 1)
val bytes = Base64.decode(imageDataBytes.toByteArray(), Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap == null) {
val show = if (uri.length > 50) uri.take(50) + "..." else uri
val malformed = RuntimeException("Malformed data uri: $show")
Logger.error(malformed) { "Could not load image" }
return null
}
return Fetcher {
DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
)
}
}
}

View File

@@ -0,0 +1,51 @@
package com.github.gotifycustom
import android.app.Application
import android.app.NotificationManager
import android.os.Build
import androidx.preference.PreferenceManager
import com.github.gotifycustom.api.CertUtils
import com.github.gotifycustom.log.LoggerHelper
import com.github.gotifycustom.log.UncaughtExceptionHandler
import com.github.gotifycustom.settings.ThemeHelper
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import org.tinylog.kotlin.Logger
class GotifyApplication : Application() {
override fun onCreate() {
LoggerHelper.init(this)
UncaughtExceptionHandler.registerCurrentThread()
Logger.info("${javaClass.simpleName}: onCreate")
val theme = PreferenceManager.getDefaultSharedPreferences(this)
.getString(getString(R.string.setting_key_theme), getString(R.string.theme_default))!!
ThemeHelper.setTheme(this, theme)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createForegroundChannel(
this,
this.getSystemService(NotificationManager::class.java)
)
}
val settings = Settings(this)
if (settings.legacyCert != null) {
Logger.info("Migrating legacy CA cert to new location")
try {
val legacyCert = settings.legacyCert
settings.legacyCert = null
val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME)
FileOutputStream(caCertFile).use {
it.write(legacyCert?.encodeToByteArray())
}
settings.caCertPath = caCertFile.absolutePath
Logger.info("Migration of legacy CA cert succeeded")
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
}
}
super.onCreate()
}
}

View File

@@ -0,0 +1,126 @@
package com.github.gotifycustom
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.style.BackgroundColorSpan
import android.text.style.BulletSpan
import android.text.style.QuoteSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import androidx.core.content.ContextCompat
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonSpansFactory
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.RenderProps
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.core.CoreProps
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TableAwareMovementMethod
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.image.AsyncDrawable
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.movement.MovementMethodPlugin
import org.commonmark.ext.gfm.tables.TableCell
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.BlockQuote
import org.commonmark.node.Code
import org.commonmark.node.Emphasis
import org.commonmark.node.Heading
import org.commonmark.node.Link
import org.commonmark.node.ListItem
import org.commonmark.node.StrongEmphasis
import org.commonmark.parser.Parser
import org.tinylog.kotlin.Logger
internal object MarkwonFactory {
fun createForMessage(context: Context, imageLoader: ImageLoader): Markwon {
return Markwon.builder(context)
.usePlugin(CorePlugin.create())
.usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create()))
.usePlugin(
CoilImagesPlugin.create(
object : CoilImagesPlugin.CoilStore {
override fun load(drawable: AsyncDrawable): ImageRequest {
return ImageRequest.Builder(context)
.data(drawable.destination)
.placeholder(R.drawable.ic_placeholder)
.listener(onError = { _, err ->
Logger.error(err.throwable) {
"Could not load markdown image: ${Utils.redactPassword(
drawable.destination
)}"
}
})
.build()
}
override fun cancel(disposable: Disposable) {
disposable.dispose()
}
},
imageLoader
)
)
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureTheme(builder: MarkwonTheme.Builder) {
builder.linkColor(ContextCompat.getColor(context, R.color.hyperLink))
.isLinkUnderlined(true)
}
})
.build()
}
fun createForNotification(context: Context, imageLoader: ImageLoader): Markwon {
val headingSizes = floatArrayOf(2f, 1.5f, 1.17f, 1f, .83f, .67f)
val bulletGapWidth = (8 * context.resources.displayMetrics.density + 0.5f).toInt()
return Markwon.builder(context)
.usePlugin(CorePlugin.create())
.usePlugin(CoilImagesPlugin.create(context, imageLoader))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
builder.setFactory(Heading::class.java) { _, props: RenderProps? ->
arrayOf<Any>(
RelativeSizeSpan(
headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]
),
StyleSpan(Typeface.BOLD)
)
}
.setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) }
.setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) }
.setFactory(BlockQuote::class.java) { _, _ -> QuoteSpan() }
.setFactory(Code::class.java) { _, _ ->
arrayOf<Any>(
BackgroundColorSpan(Color.LTGRAY),
TypefaceSpan("monospace")
)
}
.setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) }
.setFactory(Link::class.java) { _, _ -> null }
}
override fun configureParser(builder: Parser.Builder) {
builder.extensions(setOf(TablesExtension.create()))
}
override fun configureVisitor(builder: MarkwonVisitor.Builder) {
builder.on(TableCell::class.java) { visitor: MarkwonVisitor, node: TableCell? ->
visitor.visitChildren(node!!)
visitor.builder().append(' ')
}
}
})
.build()
}
}

View File

@@ -0,0 +1,64 @@
package com.github.gotifycustom
import com.github.gotifycustom.api.Api
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.Callback
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import org.tinylog.kotlin.Logger
internal class MissedMessageUtil(private val api: MessageApi) {
fun lastReceivedMessage(acceptID: (Long) -> Unit) {
api.getMessages(1, 0L).enqueue(
Callback.call(
onSuccess = Callback.SuccessBody { messages ->
if (messages.messages.size == 1) {
acceptID(messages.messages[0].id)
} else {
acceptID(NO_MESSAGES)
}
},
onError = {}
)
)
}
fun missingMessages(till: Long): List<Message?> {
val result = mutableListOf<Message?>()
try {
var since: Long? = null
while (true) {
val pagedMessages = Api.execute(api.getMessages(10, since))
val messages = pagedMessages.messages
val filtered = filter(messages, till)
result.addAll(filtered)
if (messages.size != filtered.size ||
messages.isEmpty() ||
pagedMessages.paging.next == null
) {
break
}
since = pagedMessages.paging.since
}
} catch (e: ApiException) {
Logger.error(e, "cannot retrieve missing messages")
}
return result.reversed()
}
private fun filter(messages: List<Message>, till: Long): List<Message?> {
val result = mutableListOf<Message?>()
for (message in messages) {
if (message.id > till) {
result.add(message)
} else {
break
}
}
return result
}
companion object {
const val NO_MESSAGES = 0L
}
}

View File

@@ -0,0 +1,233 @@
package com.github.gotifycustom
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.graphics.Color
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager
import com.github.gotify.client.model.Application
import org.tinylog.kotlin.Logger
internal object NotificationSupport {
@RequiresApi(Build.VERSION_CODES.O)
fun createForegroundChannel(context: Context, notificationManager: NotificationManager) {
// Low importance so that persistent notification can be sorted towards bottom of
// notification shade. Also prevents vibrations caused by persistent notification
val foreground = NotificationChannel(
Channel.FOREGROUND,
context.getString(R.string.notification_channel_title_foreground),
NotificationManager.IMPORTANCE_LOW
).apply {
setShowBadge(false)
}
notificationManager.createNotificationChannel(foreground)
}
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(
context: Context,
notificationManager: NotificationManager,
applications: List<Application>
) {
if (areAppChannelsRequested(context)) {
notificationManager.notificationChannels.forEach { channel ->
if (channel.id != Channel.FOREGROUND) {
notificationManager.deleteNotificationChannel(channel.id)
}
}
applications.forEach { app ->
createAppChannels(context, notificationManager, app.id.toString(), app.name)
}
} else {
notificationManager.notificationChannelGroups.forEach { group ->
notificationManager.deleteNotificationChannelGroup(group.id)
}
createGeneralChannels(context, notificationManager)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createGeneralChannels(context: Context, notificationManager: NotificationManager) {
try {
val messagesImportanceMin = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_MIN,
context.getString(R.string.notification_channel_title_min),
NotificationManager.IMPORTANCE_MIN
)
val messagesImportanceLow = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_LOW,
context.getString(R.string.notification_channel_title_low),
NotificationManager.IMPORTANCE_LOW
)
val messagesImportanceDefault = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_DEFAULT,
context.getString(R.string.notification_channel_title_normal),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
}
val messagesImportanceHigh = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_HIGH,
context.getString(R.string.notification_channel_title_high),
NotificationManager.IMPORTANCE_HIGH
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
}
notificationManager.createNotificationChannel(messagesImportanceMin)
notificationManager.createNotificationChannel(messagesImportanceLow)
notificationManager.createNotificationChannel(messagesImportanceDefault)
notificationManager.createNotificationChannel(messagesImportanceHigh)
} catch (e: Exception) {
Logger.error(e, "Could not create channel")
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
fun createChannelIfNonexistent(context: Context, groupId: String, channelId: String) {
if (!doesNotificationChannelExist(context, channelId)) {
val notificationManager = (context as Service)
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createAppChannels(context, notificationManager, groupId, groupId)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createAppChannels(
context: Context,
notificationManager: NotificationManager,
groupId: String,
groupName: String
) {
try {
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(
groupId,
groupName
)
)
val messagesImportanceMin = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_MIN, groupId),
context.getString(R.string.notification_channel_title_min),
NotificationManager.IMPORTANCE_MIN
).apply {
group = groupId
}
val messagesImportanceLow = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_LOW, groupId),
context.getString(R.string.notification_channel_title_low),
NotificationManager.IMPORTANCE_LOW
).apply {
group = groupId
}
val messagesImportanceDefault = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_DEFAULT, groupId),
context.getString(R.string.notification_channel_title_normal),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
group = groupId
}
val messagesImportanceHigh = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_HIGH, groupId),
context.getString(R.string.notification_channel_title_high),
NotificationManager.IMPORTANCE_HIGH
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
group = groupId
}
notificationManager.createNotificationChannel(messagesImportanceMin)
notificationManager.createNotificationChannel(messagesImportanceLow)
notificationManager.createNotificationChannel(messagesImportanceDefault)
notificationManager.createNotificationChannel(messagesImportanceHigh)
} catch (e: Exception) {
Logger.error(e, "Could not create channel")
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
private fun doesNotificationChannelExist(context: Context, channelId: String): Boolean {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = manager.getNotificationChannel(channelId)
return channel != null
}
/**
* Map {@link com.github.gotify.client.model.Message#getPriority() Gotify message priorities to
* Android channels.
*
* <pre>
* Gotify Priority | Android Importance
* <= 0 | min
* 1-3 | low
* 4-7 | default
* >= 8 | high
* </pre>
*
* @param priority the Gotify priority to convert to a notification channel as a long.
* @return the identifier of the notification channel as a String.
*/
fun convertPriorityToChannel(priority: Long): String {
return if (priority < 1) {
Channel.MESSAGES_IMPORTANCE_MIN
} else if (priority < 4) {
Channel.MESSAGES_IMPORTANCE_LOW
} else if (priority < 8) {
Channel.MESSAGES_IMPORTANCE_DEFAULT
} else {
Channel.MESSAGES_IMPORTANCE_HIGH
}
}
private fun getChannelID(importance: String, groupId: String): String {
return "$groupId::$importance"
}
fun getChannelID(priority: Long, groupId: String): String {
return getChannelID(convertPriorityToChannel(priority), groupId)
}
fun areAppChannelsRequested(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.setting_key_notification_channels),
context.resources.getBoolean(R.bool.notification_channels)
)
}
object Group {
const val MESSAGES = "GOTIFY_GROUP_MESSAGES"
}
object Channel {
const val FOREGROUND = "gotify_foreground"
const val MESSAGES_IMPORTANCE_MIN = "gotify_messages_min_importance"
const val MESSAGES_IMPORTANCE_LOW = "gotify_messages_low_importance"
const val MESSAGES_IMPORTANCE_DEFAULT = "gotify_messages_default_importance"
const val MESSAGES_IMPORTANCE_HIGH = "gotify_messages_high_importance"
}
object ID {
const val FOREGROUND = -1
const val GROUPED = -2
}
}

View File

@@ -0,0 +1,8 @@
package com.github.gotifycustom
internal data class SSLSettings(
val validateSSL: Boolean,
val caCertPath: String?,
val clientCertPath: String?,
val clientCertPassword: String?
)

View File

@@ -0,0 +1,89 @@
package com.github.gotifycustom
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.gotify.client.model.User
internal class Settings(context: Context) {
private val sharedPreferences: SharedPreferences
val filesDir: String
var url: String
get() = sharedPreferences.getString("url", "https://hdm08q1b95h.sn.mynetname.net")!!
set(value) = sharedPreferences.edit { putString("url", value) }
var token: String?
get() = sharedPreferences.getString("token", null)
set(value) = sharedPreferences.edit { putString("token", value) }
var user: User? = null
get() {
val username = sharedPreferences.getString("username", null)
val admin = sharedPreferences.getBoolean("admin", false)
return if (username != null) {
User().name(username).admin(admin)
} else {
User().name("UNKNOWN").admin(false)
}
}
private set
var serverVersion: String
get() = sharedPreferences.getString("version", "UNKNOWN")!!
set(value) = sharedPreferences.edit { putString("version", value) }
var legacyCert: String?
get() = sharedPreferences.getString("cert", null)
set(value) = sharedPreferences.edit(commit = true) { putString("cert", value) }.toUnit()
var caCertPath: String?
get() = sharedPreferences.getString("caCertPath", null)
set(value) = sharedPreferences
.edit(commit = true) { putString("caCertPath", value) }
.toUnit()
var validateSSL: Boolean
get() = sharedPreferences.getBoolean("validateSSL", true)
set(value) = sharedPreferences.edit { putBoolean("validateSSL", value) }
var clientCertPath: String?
get() = sharedPreferences.getString("clientCertPath", null)
set(value) = sharedPreferences.edit { putString("clientCertPath", value) }
var clientCertPassword: String?
get() = sharedPreferences.getString("clientCertPass", null)
set(value) = sharedPreferences.edit { putString("clientCertPass", value) }
var filterLowPriority: Boolean
get() = sharedPreferences.getBoolean("filter_low_priority", false)
set(value) = sharedPreferences.edit { putBoolean("filter_low_priority", value) }
init {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
filesDir = context.filesDir.absolutePath
}
fun tokenExists(): Boolean = !token.isNullOrEmpty()
fun clear() {
url = ""
token = null
validateSSL = true
legacyCert = null
caCertPath = null
clientCertPath = null
clientCertPassword = null
}
fun setUser(name: String?, admin: Boolean) {
sharedPreferences.edit { putString("username", name).putBoolean("admin", admin) }
}
fun sslSettings(): SSLSettings {
return SSLSettings(
validateSSL,
caCertPath,
clientCertPath,
clientCertPassword
)
}
fun shouldNotify(priority: Long): Boolean {
return !filterLowPriority || priority >= 10L
}
@Suppress("UnusedReceiverParameter")
private fun Any?.toUnit() = Unit
}

View File

@@ -0,0 +1,105 @@
package com.github.gotifycustom
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.graphics.drawable.Drawable
import android.text.format.DateUtils
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import coil.target.Target
import com.github.gotify.client.JSON
import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
import java.net.URL
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.threeten.bp.OffsetDateTime
import org.tinylog.kotlin.Logger
internal object Utils {
val JSON: Gson = JSON().gson
fun showSnackBar(activity: Activity, message: String?) {
val rootView = activity.window.decorView.findViewById<View>(android.R.id.content)
Snackbar.make(rootView, message!!, Snackbar.LENGTH_SHORT).show()
}
fun longToInt(value: Long): Int {
return (value % Int.MAX_VALUE).toInt()
}
fun dateToRelative(data: OffsetDateTime): String {
val time = data.toInstant().toEpochMilli()
val now = System.currentTimeMillis()
return DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS)
.toString()
}
fun resolveAbsoluteUrl(baseURL: String, target: String?): String? {
return if (target == null) {
null
} else {
try {
val targetUri = URI(target)
if (targetUri.isAbsolute) {
target
} else {
URL(URL(baseURL), target).toString()
}
} catch (e: MalformedURLException) {
Logger.error(e, "Could not resolve absolute url")
target
} catch (e: URISyntaxException) {
Logger.error(e, "Could not resolve absolute url")
target
}
}
}
fun toDrawable(drawableReceiver: DrawableReceiver): Target {
return object : Target {
override fun onSuccess(result: Drawable) {
drawableReceiver.loaded(result)
}
override fun onError(error: Drawable?) {
Logger.error("Bitmap failed")
}
}
}
fun AppCompatActivity.launchCoroutine(
dispatcher: CoroutineDispatcher = Dispatchers.IO,
action: suspend (coroutineScope: CoroutineScope) -> Unit
) {
this.lifecycleScope.launch(dispatcher) {
action(this)
}
}
fun interface DrawableReceiver {
fun loaded(drawable: Drawable?)
}
fun setExcludeFromRecent(context: Context, excludeFromRecent: Boolean) {
context.getSystemService(ActivityManager::class.java).appTasks?.getOrNull(0)
?.setExcludeFromRecents(excludeFromRecent)
}
fun redactPassword(stringUrl: String?): String {
val url = stringUrl?.toHttpUrlOrNull()
return when {
url == null -> "unknown"
url.password.isEmpty() -> url.toString()
else -> url.newBuilder().password("REDACTED").toString()
}
}
}

View File

@@ -0,0 +1,34 @@
package com.github.gotifycustom.api
import java.io.IOException
import retrofit2.Call
internal object Api {
@Throws(ApiException::class)
fun execute(call: Call<Void>) {
try {
val response = call.execute()
if (!response.isSuccessful) {
throw ApiException(response)
}
} catch (e: IOException) {
throw ApiException(e)
}
}
@Throws(ApiException::class)
fun <T> execute(call: Call<T>): T {
try {
val response = call.execute()
if (response.isSuccessful) {
return response.body() ?: throw ApiException("null response", response)
} else {
throw ApiException(response)
}
} catch (e: IOException) {
throw ApiException(e)
}
}
}

View File

@@ -0,0 +1,31 @@
package com.github.gotifycustom.api
import java.io.IOException
import retrofit2.Response
internal class ApiException : Exception {
var body: String = ""
private set
var code: Int
private set
constructor(response: Response<*>) : super("Api Error", null) {
body = try {
if (response.errorBody() != null) response.errorBody()!!.string() else ""
} catch (e: IOException) {
"Error while getting error body :("
}
code = response.code()
}
constructor(exceptionBody: String, response: Response<*>) : super("Api Error", null) {
body = exceptionBody
code = response.code()
}
constructor(cause: Throwable?) : super("Request failed.", cause) {
code = 0
}
override fun toString() = "Code($code) Response: ${body.take(200)}"
}

View File

@@ -0,0 +1,85 @@
package com.github.gotifycustom.api
import android.app.Activity
import org.tinylog.kotlin.Logger
import retrofit2.Call
import retrofit2.Response
internal class Callback<T> private constructor(
private val onSuccess: SuccessCallback<T>,
private val onError: ErrorCallback
) {
fun interface SuccessCallback<T> {
fun onSuccess(response: Response<T>)
}
fun interface SuccessBody<T> : SuccessCallback<T> {
override fun onSuccess(response: Response<T>) {
onResultSuccess(response.body() ?: throw ApiException("null response", response))
}
fun onResultSuccess(data: T)
}
fun interface ErrorCallback {
fun onError(t: ApiException)
}
private class RetrofitCallback<T>(private val callback: Callback<T>) : retrofit2.Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
callback.onSuccess.onSuccess(response)
} else {
callback.onError.onError(ApiException(response))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
callback.onError.onError(ApiException(t))
}
}
companion object {
fun <T> callInUI(
context: Activity,
onSuccess: SuccessCallback<T>,
onError: ErrorCallback
): retrofit2.Callback<T> {
return call(
onSuccess = { response -> context.runOnUiThread { onSuccess.onSuccess(response) } },
onError = { exception -> context.runOnUiThread { onError.onError(exception) } }
)
}
fun <T> call(
onSuccess: SuccessCallback<T> = SuccessCallback {},
onError: ErrorCallback = ErrorCallback {}
): retrofit2.Callback<T> {
return RetrofitCallback(merge(of(onSuccess, onError), errorCallback()))
}
private fun <T> of(onSuccess: SuccessCallback<T>, onError: ErrorCallback): Callback<T> {
return Callback(onSuccess, onError)
}
private fun <T> errorCallback(): Callback<T> {
return Callback(
onSuccess = {},
onError = { exception -> Logger.error(exception, "Error while api call") }
)
}
private fun <T> merge(left: Callback<T>, right: Callback<T>): Callback<T> {
return Callback(
onSuccess = { data ->
left.onSuccess.onSuccess(data)
right.onSuccess.onSuccess(data)
},
onError = { exception ->
left.onError.onError(exception)
right.onError.onError(exception)
}
)
}
}
}

View File

@@ -0,0 +1,127 @@
package com.github.gotifycustom.api
import android.annotation.SuppressLint
import com.github.gotifycustom.SSLSettings
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger
internal object CertUtils {
const val CA_CERT_NAME = "ca-cert.crt"
const val CLIENT_CERT_NAME = "client-cert.p12"
@SuppressLint("CustomX509TrustManager")
private val trustAll = object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}
fun parseCertificate(inputStream: InputStream): Certificate {
try {
val certificateFactory = CertificateFactory.getInstance("X509")
return certificateFactory.generateCertificate(inputStream)
} catch (e: Exception) {
throw IllegalArgumentException("certificate is invalid")
}
}
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package.
try {
val trustManagers = mutableSetOf<TrustManager>()
val keyManagers = mutableSetOf<KeyManager>()
if (settings.validateSSL) {
// Custom SSL validation
settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) }
} else {
// Disable SSL validation
trustManagers.add(trustAll)
builder.hostnameVerifier { _, _ -> true }
}
settings.clientCertPath?.let {
keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
}
if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) {
if (trustManagers.isEmpty()) {
// Fall back to system trust managers
trustManagers.addAll(defaultSystemTrustManager())
}
val context = SSLContext.getInstance("TLS")
context.init(
keyManagers.toTypedArray(),
trustManagers.toTypedArray(),
SecureRandom()
)
builder.sslSocketFactory(
context.socketFactory,
trustManagers.elementAt(0) as X509TrustManager
)
}
} catch (e: Exception) {
// We shouldn't have issues since the cert is verified on login.
Logger.error(e, "Failed to apply SSL settings")
}
}
@Throws(GeneralSecurityException::class)
private fun certToTrustManager(certPath: String): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificates = FileInputStream(File(certPath)).use(
certificateFactory::generateCertificates
)
require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" }
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) }
certificates.forEachIndexed { index, certificate ->
val certificateAlias = "ca$index"
caKeyStore.setCertificateEntry(certificateAlias, certificate)
}
val trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(caKeyStore)
return trustManagerFactory.trustManagers
}
@Throws(GeneralSecurityException::class)
private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
require(certPassword != null) { "empty client certificate password" }
val keyStore = KeyStore.getInstance("PKCS12")
FileInputStream(File(certPath)).use {
keyStore.load(it, certPassword.toCharArray())
}
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, certPassword.toCharArray())
return keyManagerFactory.keyManagers
}
private fun defaultSystemTrustManager(): Array<TrustManager> {
val trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
)
trustManagerFactory.init(null as KeyStore?)
return trustManagerFactory.trustManagers
}
}

View File

@@ -0,0 +1,63 @@
package com.github.gotifycustom.api
import com.github.gotifycustom.SSLSettings
import com.github.gotifycustom.Settings
import com.github.gotify.client.ApiClient
import com.github.gotify.client.api.UserApi
import com.github.gotify.client.api.VersionApi
import com.github.gotify.client.auth.ApiKeyAuth
import com.github.gotify.client.auth.HttpBasicAuth
internal object ClientFactory {
private fun unauthorized(
settings: Settings,
sslSettings: SSLSettings,
baseUrl: String
): ApiClient {
return defaultClient(arrayOf(), settings, sslSettings, baseUrl)
}
fun basicAuth(
settings: Settings,
sslSettings: SSLSettings,
username: String,
password: String
): ApiClient {
val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings)
val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth
auth.username = username
auth.password = password
return client
}
fun clientToken(settings: Settings, token: String? = settings.token): ApiClient {
val client = defaultClient(arrayOf("clientTokenHeader"), settings)
val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth
tokenAuth.apiKey = token
return client
}
fun versionApi(
settings: Settings,
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
): VersionApi {
return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java)
}
fun userApiWithToken(settings: Settings): UserApi {
return clientToken(settings).createService(UserApi::class.java)
}
private fun defaultClient(
authentications: Array<String>,
settings: Settings,
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
): ApiClient {
val client = ApiClient(authentications)
CertUtils.applySslSettings(client.okBuilder, sslSettings)
client.adapterBuilder.baseUrl("$baseUrl/")
return client
}
}

View File

@@ -0,0 +1,25 @@
package com.github.gotifycustom.init
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.github.gotifycustom.Settings
import com.github.gotifycustom.service.WebSocketService
internal class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val settings = Settings(context)
if (!settings.tokenExists()) {
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(Intent(context, WebSocketService::class.java))
} else {
context.startService(Intent(context, WebSocketService::class.java))
}
}
}

View File

@@ -0,0 +1,221 @@
package com.github.gotifycustom.init
import android.Manifest
import android.app.AlarmManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.github.gotifycustom.R
import com.github.gotifycustom.Settings
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.Callback
import com.github.gotifycustom.api.Callback.SuccessCallback
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.model.User
import com.github.gotify.client.model.VersionInfo
import com.github.gotifycustom.login.LoginActivity
import com.github.gotifycustom.messages.MessagesActivity
import com.github.gotifycustom.service.WebSocketService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.livinglifetechway.quickpermissionskotlin.runWithPermissions
import com.livinglifetechway.quickpermissionskotlin.util.QuickPermissionsOptions
import com.livinglifetechway.quickpermissionskotlin.util.QuickPermissionsRequest
import org.tinylog.kotlin.Logger
internal class InitializationActivity : AppCompatActivity() {
private lateinit var settings: Settings
private var splashScreenActive = true
@RequiresApi(Build.VERSION_CODES.S)
private val activityResultLauncher =
registerForActivityResult(StartActivityForResult()) {
requestAlarmPermissionOrAuthenticate()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings = Settings(this)
Logger.info("Entering ${javaClass.simpleName}")
installSplashScreen().setKeepOnScreenCondition { splashScreenActive }
if (settings.tokenExists()) {
runWithPostNotificationsPermission {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.TIRAMISU) {
// Android 14 and above
requestAlarmPermissionOrAuthenticate()
} else {
// Android 13 and below
tryAuthenticate()
}
}
} else {
showLogin()
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun requestAlarmPermissionOrAuthenticate() {
val manager = ContextCompat.getSystemService(this, AlarmManager::class.java)
if (manager?.canScheduleExactAlarms() == true) {
tryAuthenticate()
} else {
stopSlashScreen()
alarmDialog()
}
}
private fun showLogin() {
splashScreenActive = false
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
private fun tryAuthenticate() {
ClientFactory.userApiWithToken(settings)
.currentUser()
.enqueue(
Callback.callInUI(
this,
onSuccess = Callback.SuccessBody { user -> authenticated(user) },
onError = { exception -> failed(exception) }
)
)
}
private fun failed(exception: ApiException) {
stopSlashScreen()
when (exception.code) {
0 -> {
dialog(getString(R.string.not_available, settings.url))
return
}
401 -> {
dialog(getString(R.string.auth_failed))
return
}
}
var response = exception.body
response = response.take(200)
dialog(getString(R.string.other_error, settings.url, exception.code, response))
}
private fun dialog(message: String) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.oops)
.setMessage(message)
.setPositiveButton(R.string.retry) { _, _ -> tryAuthenticate() }
.setNegativeButton(R.string.logout) { _, _ -> showLogin() }
.setCancelable(false)
.show()
}
@RequiresApi(Build.VERSION_CODES.S)
private fun alarmDialog() {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.permissions_alarm_prompt))
.setPositiveButton(getString(R.string.permissions_dialog_grant)) { _, _ ->
Intent(
android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM,
"package:$packageName".toUri()
).apply {
activityResultLauncher.launch(this)
}
}
.setCancelable(false)
.show()
}
private fun authenticated(user: User) {
Logger.info("Authenticated as ${user.name}")
settings.setUser(user.name, user.isAdmin)
requestVersion {
splashScreenActive = false
startActivity(Intent(this, MessagesActivity::class.java))
finish()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(Intent(this, WebSocketService::class.java))
} else {
startService(Intent(this, WebSocketService::class.java))
}
}
private fun requestVersion(runnable: Runnable) {
requestVersion(
callback = Callback.SuccessBody { version: VersionInfo ->
Logger.info("Server version: ${version.version}@${version.buildDate}")
settings.serverVersion = version.version
runnable.run()
},
errorCallback = { runnable.run() }
)
}
private fun requestVersion(
callback: SuccessCallback<VersionInfo>,
errorCallback: Callback.ErrorCallback
) {
ClientFactory.versionApi(settings)
.version
.enqueue(Callback.callInUI(this, callback, errorCallback))
}
private fun runWithPostNotificationsPermission(action: () -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13 and above
val quickPermissionsOption = QuickPermissionsOptions(
handleRationale = true,
handlePermanentlyDenied = true,
preRationaleAction = { stopSlashScreen() },
rationaleMethod = { req -> processPermissionRationale(req) },
permissionsDeniedMethod = { req -> processPermissionRationale(req) },
permanentDeniedMethod = { req -> processPermissionsPermanentDenied(req) }
)
runWithPermissions(
Manifest.permission.POST_NOTIFICATIONS,
options = quickPermissionsOption,
callback = action
)
} else {
// Android 12 and below
action()
}
}
private fun stopSlashScreen() {
splashScreenActive = false
setContentView(R.layout.splash)
}
private fun processPermissionRationale(req: QuickPermissionsRequest) {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.permissions_notification_denied_temp))
.setPositiveButton(getString(R.string.permissions_dialog_grant)) { _, _ ->
req.proceed()
}
.setCancelable(false)
.show()
}
private fun processPermissionsPermanentDenied(req: QuickPermissionsRequest) {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.permissions_notification_denied_permanent))
.setPositiveButton(getString(R.string.permissions_dialog_grant)) { _, _ ->
req.openAppSettings()
}
.setCancelable(false)
.show()
}
}

View File

@@ -0,0 +1,43 @@
package com.github.gotifycustom.log
import android.content.Context
import java.io.File
import org.tinylog.kotlin.Logger
class LoggerHelper {
companion object {
fun read(ctx: Context): String = folder(ctx)
.listFiles()
.orEmpty()
.flatMap { it.readLines() }
.fold(mutableListOf<String>()) { newLines, line -> groupExceptions(newLines, line) }
.takeLast(200)
.reversed()
.joinToString(separator = "\n")
private fun groupExceptions(
newLines: MutableList<String>,
line: String
): MutableList<String> {
if (newLines.isNotEmpty() && (line.startsWith('\t') || line.startsWith("Caused"))) {
newLines[newLines.lastIndex] += '\n' + line
} else {
newLines.add(line)
}
return newLines
}
fun clear(ctx: Context) {
folder(ctx).listFiles()?.forEach { it.writeText("") }
Logger.info("Logs cleared")
}
fun init(ctx: Context) {
val file = folder(ctx)
file.mkdirs()
System.setProperty("tinylog.directory", file.absolutePath)
}
private fun folder(ctx: Context): File = File(ctx.filesDir, "log")
}
}

View File

@@ -0,0 +1,85 @@
package com.github.gotifycustom.log
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import com.github.gotifycustom.R
import com.github.gotifycustom.Utils
import com.github.gotifycustom.Utils.launchCoroutine
import com.github.gotifycustom.databinding.ActivityLogsBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tinylog.kotlin.Logger
internal class LogsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLogsBinding
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLogsBinding.inflate(layoutInflater)
setContentView(binding.root)
Logger.info("Entering ${javaClass.simpleName}")
updateLogs()
setSupportActionBar(binding.appBarDrawer.toolbar)
val actionBar = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowCustomEnabled(true)
}
}
private fun updateLogs() {
launchCoroutine {
val log = LoggerHelper.read(this)
withContext(Dispatchers.Main) {
val content = binding.logContent
if (content.selectionStart == content.selectionEnd) {
content.text = log
}
}
}
if (!isDestroyed) {
handler.postDelayed({ updateLogs() }, 5000)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.logs_action, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
finish()
true
}
R.id.action_delete_logs -> {
LoggerHelper.clear(this)
binding.logContent.text = null
true
}
R.id.action_copy_logs -> {
val content = binding.logContent
val clipboardManager =
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("GotifyLog", content.text.toString())
clipboardManager.setPrimaryClip(clipData)
Utils.showSnackBar(this, getString(R.string.logs_copied))
true
}
else -> false
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.gotifycustom.log
import org.tinylog.kotlin.Logger
internal object UncaughtExceptionHandler {
fun registerCurrentThread() {
Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable ->
Logger.error(e, "uncaught exception")
}
}
}

View File

@@ -0,0 +1,137 @@
package com.github.gotifycustom.login
import android.content.Context
import android.view.LayoutInflater
import android.widget.CompoundButton
import androidx.core.widget.doOnTextChanged
import com.github.gotifycustom.R
import com.github.gotifycustom.databinding.AdvancedSettingsDialogBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
internal class AdvancedDialog(
private val context: Context,
private val layoutInflater: LayoutInflater
) {
private lateinit var binding: AdvancedSettingsDialogBinding
private var onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null
private lateinit var onClickSelectCaCertificate: Runnable
private lateinit var onClickRemoveCaCertificate: Runnable
private lateinit var onClickSelectClientCertificate: Runnable
private lateinit var onClickRemoveClientCertificate: Runnable
private lateinit var onClose: (password: String) -> Unit
fun onDisableSSLChanged(
onCheckedChangeListener: CompoundButton.OnCheckedChangeListener?
): AdvancedDialog {
this.onCheckedChangeListener = onCheckedChangeListener
return this
}
fun onClickSelectCaCertificate(onClickSelectCaCertificate: Runnable): AdvancedDialog {
this.onClickSelectCaCertificate = onClickSelectCaCertificate
return this
}
fun onClickRemoveCaCertificate(onClickRemoveCaCertificate: Runnable): AdvancedDialog {
this.onClickRemoveCaCertificate = onClickRemoveCaCertificate
return this
}
fun onClickSelectClientCertificate(onClickSelectClientCertificate: Runnable): AdvancedDialog {
this.onClickSelectClientCertificate = onClickSelectClientCertificate
return this
}
fun onClickRemoveClientCertificate(onClickRemoveClientCertificate: Runnable): AdvancedDialog {
this.onClickRemoveClientCertificate = onClickRemoveClientCertificate
return this
}
fun onClose(onClose: (password: String) -> Unit): AdvancedDialog {
this.onClose = onClose
return this
}
fun show(
disableSSL: Boolean,
caCertPath: String? = null,
caCertCN: String?,
clientCertPath: String? = null,
clientCertPassword: String?
): AdvancedDialog {
binding = AdvancedSettingsDialogBinding.inflate(layoutInflater)
binding.disableSSL.isChecked = disableSSL
binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener)
if (!clientCertPassword.isNullOrEmpty()) {
binding.clientCertPasswordEdittext.setText(clientCertPassword)
}
binding.clientCertPasswordEdittext.doOnTextChanged { _, _, _, _ ->
if (binding.selectedClientCert.text.toString() ==
context.getString(R.string.certificate_found)
) {
showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty())
}
}
if (caCertPath == null) {
showSelectCaCertificate()
} else {
showRemoveCaCertificate(caCertCN!!)
}
if (clientCertPath == null) {
showSelectClientCertificate()
} else {
showRemoveClientCertificate()
}
MaterialAlertDialogBuilder(context)
.setView(binding.root)
.setTitle(R.string.advanced_settings)
.setPositiveButton(context.getString(R.string.done), null)
.setOnDismissListener {
onClose(binding.clientCertPasswordEdittext.text.toString())
}
.show()
return this
}
private fun showSelectCaCertificate() {
binding.toggleCaCert.setText(R.string.select_ca_certificate)
binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() }
binding.selectedCaCert.setText(R.string.no_certificate_selected)
}
fun showRemoveCaCertificate(certificateCN: String) {
binding.toggleCaCert.setText(R.string.remove_ca_certificate)
binding.toggleCaCert.setOnClickListener {
showSelectCaCertificate()
onClickRemoveCaCertificate.run()
}
binding.selectedCaCert.text = certificateCN
}
private fun showSelectClientCertificate() {
binding.toggleClientCert.setText(R.string.select_client_certificate)
binding.toggleClientCert.setOnClickListener { onClickSelectClientCertificate.run() }
binding.selectedClientCert.setText(R.string.no_certificate_selected)
showPasswordMissing(false)
binding.clientCertPasswordEdittext.text = null
}
fun showRemoveClientCertificate() {
binding.toggleClientCert.setText(R.string.remove_client_certificate)
binding.toggleClientCert.setOnClickListener {
showSelectClientCertificate()
onClickRemoveClientCertificate.run()
}
binding.selectedClientCert.setText(R.string.certificate_found)
showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty())
}
private fun showPasswordMissing(toggled: Boolean) {
val error = if (toggled) {
context.getString(R.string.client_cert_password_missing)
} else {
null
}
binding.clientCertPassword.error = error
}
}

View File

@@ -0,0 +1,351 @@
package com.github.gotifycustom.login
import android.content.ActivityNotFoundException
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import com.github.gotifycustom.R
import com.github.gotifycustom.SSLSettings
import com.github.gotifycustom.Settings
import com.github.gotifycustom.Utils
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.Callback
import com.github.gotifycustom.api.Callback.SuccessCallback
import com.github.gotifycustom.api.CertUtils
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.ApiClient
import com.github.gotify.client.api.ClientApi
import com.github.gotify.client.api.UserApi
import com.github.gotify.client.model.Client
import com.github.gotify.client.model.ClientParams
import com.github.gotify.client.model.VersionInfo
import com.github.gotifycustom.databinding.ActivityLoginBinding
import com.github.gotifycustom.databinding.ClientNameDialogBinding
import com.github.gotifycustom.init.InitializationActivity
import com.github.gotifycustom.log.LogsActivity
import com.github.gotifycustom.log.UncaughtExceptionHandler
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.security.cert.X509Certificate
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.tinylog.kotlin.Logger
internal class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private lateinit var settings: Settings
private var disableSslValidation = false
private var caCertCN: String? = null
private var caCertPath: String? = null
private var clientCertPath: String? = null
private var clientCertPassword: String? = null
private lateinit var advancedDialog: AdvancedDialog
private val caDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
requireNotNull(result.data) { "file path was null" }
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CA_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
// temporarily store it (don't store to settings until they decide to login)
caCertCN = getNameOfCertContent(destinationFile) ?: "unknown"
caCertPath = destinationFile.absolutePath
advancedDialog.showRemoveCaCertificate(caCertCN!!)
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message))
}
}
private val clientCertDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
requireNotNull(result.data) { "file path was null" }
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CLIENT_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
// temporarily store it (don't store to settings until they decide to login)
clientCertPath = destinationFile.absolutePath
advancedDialog.showRemoveClientCertificate()
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_client_failed, e.message))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UncaughtExceptionHandler.registerCurrentThread()
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
Logger.info("Entering ${javaClass.simpleName}")
settings = Settings(this)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.gotifyUrlEditext.setText(settings.url)
binding.gotifyUrlEditext.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {}
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {
invalidateUrl()
}
override fun afterTextChanged(editable: Editable) {}
})
binding.checkurl.setOnClickListener { doCheckUrl() }
binding.openLogs.setOnClickListener { openLogs() }
binding.advancedSettings.setOnClickListener { toggleShowAdvanced() }
binding.login.setOnClickListener { doLogin() }
}
private fun invalidateUrl() {
binding.username.visibility = View.GONE
binding.password.visibility = View.GONE
binding.login.visibility = View.GONE
binding.checkurl.text = getString(R.string.check_url)
}
private fun doCheckUrl() {
val url = binding.gotifyUrlEditext.text.toString().trim().trimEnd('/')
val parsedUrl = url.toHttpUrlOrNull()
if (parsedUrl == null) {
Utils.showSnackBar(this, "Invalid URL (include http:// or https://)")
return
}
if ("http" == parsedUrl.scheme) {
showHttpWarning()
}
binding.checkurlProgress.visibility = View.VISIBLE
binding.checkurl.visibility = View.GONE
try {
ClientFactory.versionApi(settings, tempSslSettings(), url)
.version
.enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url)))
} catch (e: Exception) {
binding.checkurlProgress.visibility = View.GONE
binding.checkurl.visibility = View.VISIBLE
val errorMsg = getString(R.string.version_failed, "$url/version", e.message)
Utils.showSnackBar(this, errorMsg)
}
}
private fun showHttpWarning() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setCancelable(true)
.setMessage(R.string.http_warning)
.setPositiveButton(R.string.i_understand, null)
.show()
}
private fun openLogs() {
startActivity(Intent(this, LogsActivity::class.java))
}
private fun toggleShowAdvanced() {
advancedDialog = AdvancedDialog(this, layoutInflater)
.onDisableSSLChanged { _, disable ->
invalidateUrl()
disableSslValidation = disable
}
.onClickSelectCaCertificate {
invalidateUrl()
doSelectCertificate(caDialogResultLauncher, R.string.select_ca_file)
}
.onClickRemoveCaCertificate {
invalidateUrl()
caCertPath = null
clientCertPassword = null
}
.onClickSelectClientCertificate {
invalidateUrl()
doSelectCertificate(clientCertDialogResultLauncher, R.string.select_client_file)
}
.onClickRemoveClientCertificate {
invalidateUrl()
clientCertPath = null
}
.onClose { newPassword ->
clientCertPassword = newPassword
}
.show(
disableSslValidation,
caCertPath,
caCertCN,
clientCertPath,
clientCertPassword
)
}
private fun doSelectCertificate(
resultLauncher: ActivityResultLauncher<Intent>,
@StringRes descriptionId: Int
) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
// we don't really care what kind of file it is as long as we can parse it
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
resultLauncher.launch(Intent.createChooser(intent, getString(descriptionId)))
} catch (_: ActivityNotFoundException) {
// case for user not having a file browser installed
Utils.showSnackBar(this, getString(R.string.please_install_file_browser))
}
}
private fun getNameOfCertContent(file: File): String? {
val ca = FileInputStream(file).use { CertUtils.parseCertificate(it) }
return (ca as X509Certificate).subjectX500Principal.name
}
private fun onValidUrl(url: String): SuccessCallback<VersionInfo> {
return Callback.SuccessBody { version ->
settings.url = url
binding.checkurlProgress.visibility = View.GONE
binding.checkurl.visibility = View.VISIBLE
binding.checkurl.text = getString(R.string.found_gotify_version, version.version)
binding.username.visibility = View.VISIBLE
binding.username.requestFocus()
binding.password.visibility = View.VISIBLE
binding.login.visibility = View.VISIBLE
}
}
private fun onInvalidUrl(url: String): Callback.ErrorCallback {
return Callback.ErrorCallback { exception ->
binding.checkurlProgress.visibility = View.GONE
binding.checkurl.visibility = View.VISIBLE
Utils.showSnackBar(this, versionError(url, exception))
}
}
private fun doLogin() {
val username = binding.usernameEditext.text.toString()
val password = binding.passwordEditext.text.toString()
binding.login.visibility = View.GONE
binding.loginProgress.visibility = View.VISIBLE
val client = ClientFactory.basicAuth(settings, tempSslSettings(), username, password)
client.createService(UserApi::class.java)
.currentUser()
.enqueue(
Callback.callInUI(
this,
onSuccess = { newClientDialog(client) },
onError = { onInvalidLogin() }
)
)
}
private fun onInvalidLogin() {
binding.login.visibility = View.VISIBLE
binding.loginProgress.visibility = View.GONE
Utils.showSnackBar(this, getString(R.string.wronguserpw))
}
private fun newClientDialog(client: ApiClient) {
val clientDialogBinding = ClientNameDialogBinding.inflate(layoutInflater)
val clientDialogEditext = clientDialogBinding.clientNameEditext
clientDialogEditext.setText(Build.MODEL)
MaterialAlertDialogBuilder(this)
.setTitle(R.string.create_client_title)
.setMessage(R.string.create_client_message)
.setView(clientDialogBinding.root)
.setPositiveButton(R.string.create, doCreateClient(client, clientDialogEditext))
.setNegativeButton(R.string.cancel) { _, _ -> onCancelClientDialog() }
.setCancelable(false)
.show()
}
private fun doCreateClient(
client: ApiClient,
nameProvider: TextInputEditText
): DialogInterface.OnClickListener {
return DialogInterface.OnClickListener { _, _ ->
val newClient = ClientParams().name(nameProvider.text.toString())
client.createService(ClientApi::class.java)
.createClient(newClient)
.enqueue(
Callback.callInUI(
this,
onSuccess = Callback.SuccessBody { client -> onCreatedClient(client) },
onError = { onFailedToCreateClient() }
)
)
}
}
private fun onCreatedClient(client: Client) {
settings.token = client.token
settings.validateSSL = !disableSslValidation
settings.caCertPath = caCertPath
settings.clientCertPath = clientCertPath
settings.clientCertPassword = clientCertPassword
Utils.showSnackBar(this, getString(R.string.created_client))
startActivity(Intent(this, InitializationActivity::class.java))
finish()
}
private fun onFailedToCreateClient() {
Utils.showSnackBar(this, getString(R.string.create_client_failed))
binding.loginProgress.visibility = View.GONE
binding.login.visibility = View.VISIBLE
}
private fun onCancelClientDialog() {
binding.loginProgress.visibility = View.GONE
binding.login.visibility = View.VISIBLE
}
private fun versionError(url: String, exception: ApiException): String {
return getString(R.string.version_failed_status_code, "$url/version", exception.code)
}
private fun tempSslSettings(): SSLSettings {
return SSLSettings(
!disableSslValidation,
caCertPath,
clientCertPath,
clientCertPassword
)
}
private fun copyStreamToFile(inputStream: InputStream, file: File) {
FileOutputStream(file).use {
inputStream.copyTo(it)
}
}
}

View File

@@ -0,0 +1,38 @@
package com.github.gotifycustom.messages
import com.github.gotify.client.model.Message
internal object Extras {
fun useMarkdown(message: Message): Boolean = useMarkdown(message.extras)
fun useMarkdown(extras: Map<String, Any>?): Boolean {
if (extras == null) {
return false
}
val display: Any? = extras["client::display"]
if (display !is Map<*, *>) {
return false
}
return "text/markdown" == display["contentType"]
}
fun <T> getNestedValue(clazz: Class<T>, extras: Map<String, Any>?, vararg keys: String): T? {
var value: Any? = extras
keys.forEach { key ->
if (value == null) {
return null
}
value = (value as Map<*, *>)[key]
}
if (!clazz.isInstance(value)) {
return null
}
return clazz.cast(value)
}
}

View File

@@ -0,0 +1,33 @@
package com.github.gotifycustom.messages
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import com.github.gotifycustom.databinding.ActivityDialogIntentUrlBinding
internal class IntentUrlDialogActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setFinishOnTouchOutside(false)
val binding = ActivityDialogIntentUrlBinding.inflate(layoutInflater)
val intentUrl = intent.getStringExtra(EXTRA_KEY_URL)
assert(intentUrl != null) { "intentUrl may not be empty" }
binding.urlView.text = intentUrl
binding.openButton.setOnClickListener {
finish()
Intent(Intent.ACTION_VIEW).apply {
data = intentUrl?.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(this)
}
}
binding.cancelButton.setOnClickListener { finish() }
setContentView(binding.root)
}
companion object {
const val EXTRA_KEY_URL = "url"
}
}

View File

@@ -0,0 +1,207 @@
package com.github.gotifycustom.messages
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.text.format.DateUtils
import android.text.util.Linkify
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil.ImageLoader
import coil.load
import com.github.gotifycustom.MarkwonFactory
import com.github.gotifycustom.R
import com.github.gotifycustom.Settings
import com.github.gotifycustom.Utils
import com.github.gotify.client.model.Message
import com.github.gotifycustom.databinding.MessageItemBinding
import com.github.gotifycustom.databinding.MessageItemCompactBinding
import com.github.gotifycustom.messages.provider.MessageWithImage
import io.noties.markwon.Markwon
import java.text.DateFormat
import java.util.Date
import org.threeten.bp.OffsetDateTime
internal class ListMessageAdapter(
private val context: Context,
private val settings: Settings,
private val imageLoader: ImageLoader,
private val delete: Delete
) : ListAdapter<MessageWithImage, ListMessageAdapter.ViewHolder>(DiffCallback) {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val markwon: Markwon = MarkwonFactory.createForMessage(context, imageLoader)
private val timeFormatRelative =
context.resources.getString(R.string.time_format_value_relative)
private val timeFormatPrefsKey = context.resources.getString(R.string.setting_key_time_format)
private var messageLayout = 0
init {
val messageLayoutPrefsKey = context.resources.getString(R.string.setting_key_message_layout)
val messageLayoutNormal = context.resources.getString(R.string.message_layout_value_normal)
val messageLayoutSetting = prefs.getString(messageLayoutPrefsKey, messageLayoutNormal)
messageLayout = if (messageLayoutSetting == messageLayoutNormal) {
R.layout.message_item
} else {
R.layout.message_item_compact
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return if (messageLayout == R.layout.message_item) {
val binding = MessageItemBinding.inflate(layoutInflater, parent, false)
ViewHolder(binding)
} else {
val binding = MessageItemCompactBinding.inflate(layoutInflater, parent, false)
ViewHolder(binding)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val message = currentList[position]
if (Extras.useMarkdown(message.message)) {
holder.message.autoLinkMask = 0
markwon.setMarkdown(holder.message, message.message.message)
} else {
holder.message.autoLinkMask = Linkify.WEB_URLS
holder.message.text = message.message.message
}
holder.title.text = message.message.title
if (message.image != null) {
val url = Utils.resolveAbsoluteUrl("${settings.url}/", message.image)
holder.image.load(url, imageLoader) {
error(R.drawable.ic_alarm)
placeholder(R.drawable.ic_placeholder)
}
}
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val timeFormat = prefs.getString(timeFormatPrefsKey, timeFormatRelative)
holder.setDateTime(message.message.date, timeFormat == timeFormatRelative)
holder.date.setOnClickListener { holder.switchTimeFormat() }
holder.delete.setOnClickListener {
delete.delete(message.message)
}
}
override fun getItemId(position: Int): Long {
val currentItem = currentList[position]
return currentItem.message.id
}
// Fix for message not being selectable (https://issuetracker.google.com/issues/37095917)
override fun onViewAttachedToWindow(holder: ViewHolder) {
super.onViewAttachedToWindow(holder)
holder.message.isEnabled = false
holder.message.isEnabled = true
}
class ViewHolder(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) {
lateinit var image: ImageView
lateinit var message: TextView
lateinit var title: TextView
lateinit var date: TextView
lateinit var delete: ImageButton
private var relativeTimeFormat = true
private lateinit var dateTime: OffsetDateTime
init {
enableCopyToClipboard()
if (binding is MessageItemBinding) {
image = binding.messageImage
message = binding.messageText
title = binding.messageTitle
date = binding.messageDate
delete = binding.messageDelete
} else if (binding is MessageItemCompactBinding) {
image = binding.messageImage
message = binding.messageText
title = binding.messageTitle
date = binding.messageDate
delete = binding.messageDelete
}
}
fun switchTimeFormat() {
relativeTimeFormat = !relativeTimeFormat
updateDate()
}
fun setDateTime(dateTime: OffsetDateTime, relativeTimeFormatPreference: Boolean) {
this.dateTime = dateTime
relativeTimeFormat = relativeTimeFormatPreference
updateDate()
}
private fun updateDate() {
val text = if (relativeTimeFormat) {
// Relative time format
Utils.dateToRelative(dateTime)
} else {
// Absolute time format
val time = dateTime.toInstant().toEpochMilli()
val date = Date(time)
if (DateUtils.isToday(time)) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
} else {
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
}
}
date.text = text
}
private fun enableCopyToClipboard() {
super.itemView.setOnLongClickListener { view: View ->
val clipboard = view.context
.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
val clip = ClipData.newPlainText("GotifyMessageContent", message.text.toString())
if (clipboard != null) {
clipboard.setPrimaryClip(clip)
Toast.makeText(
view.context,
view.context.getString(R.string.message_copied_to_clipboard),
Toast.LENGTH_SHORT
).show()
}
true
}
}
}
object DiffCallback : DiffUtil.ItemCallback<MessageWithImage>() {
override fun areItemsTheSame(
oldItem: MessageWithImage,
newItem: MessageWithImage
): Boolean {
return oldItem.message.id == newItem.message.id
}
override fun areContentsTheSame(
oldItem: MessageWithImage,
newItem: MessageWithImage
): Boolean {
return oldItem == newItem
}
}
fun interface Delete {
fun delete(message: Message)
}
}

View File

@@ -0,0 +1,658 @@
package com.github.gotifycustom.messages
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Canvas
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.request.ImageRequest
import com.github.gotifycustom.BuildConfig
import com.github.gotifycustom.CoilInstance
import com.github.gotifycustom.MissedMessageUtil
import com.github.gotifycustom.R
import com.github.gotifycustom.Utils
import com.github.gotifycustom.Utils.launchCoroutine
import com.github.gotifycustom.api.Api
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.Callback
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.client.api.ClientApi
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Client
import com.github.gotify.client.model.Message
import com.github.gotifycustom.databinding.ActivityMessagesBinding
import com.github.gotifycustom.init.InitializationActivity
import com.github.gotifycustom.log.LogsActivity
import com.github.gotifycustom.login.LoginActivity
import com.github.gotifycustom.messages.provider.MessageState
import com.github.gotifycustom.messages.provider.MessageWithImage
import com.github.gotifycustom.service.WebSocketService
import com.github.gotifycustom.settings.SettingsActivity
import com.github.gotifycustom.sharing.ShareActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tinylog.kotlin.Logger
internal class MessagesActivity :
AppCompatActivity(),
NavigationView.OnNavigationItemSelectedListener {
private lateinit var binding: ActivityMessagesBinding
private lateinit var viewModel: MessagesModel
private var isLoadMore = false
private var updateAppOnDrawerClose: Long? = null
private lateinit var listMessageAdapter: ListMessageAdapter
private lateinit var onBackPressedCallback: OnBackPressedCallback
private val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageJson = intent.getStringExtra("message")
val message = Utils.JSON.fromJson(
messageJson,
Message::class.java
)
launchCoroutine {
addSingleMessage(message)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMessagesBinding.inflate(layoutInflater)
setContentView(binding.root)
viewModel = ViewModelProvider(this, MessagesModelFactory(this))[MessagesModel::class.java]
Logger.info("Entering " + javaClass.simpleName)
initDrawer()
val layoutManager = LinearLayoutManager(this)
val messagesView: RecyclerView = binding.messagesView
val dividerItemDecoration = DividerItemDecoration(
messagesView.context,
layoutManager.orientation
)
listMessageAdapter = ListMessageAdapter(
this,
viewModel.settings,
CoilInstance.get(this)
) { message ->
scheduleDeletion(message)
}
addBackPressCallback()
messagesView.addItemDecoration(dividerItemDecoration)
messagesView.setHasFixedSize(true)
messagesView.layoutManager = layoutManager
messagesView.addOnScrollListener(MessageListOnScrollListener())
messagesView.adapter = listMessageAdapter
val appsHolder = viewModel.appsHolder
appsHolder.onUpdate { onUpdateApps(appsHolder.get()) }
if (appsHolder.wasRequested()) onUpdateApps(appsHolder.get()) else appsHolder.request()
val itemTouchHelper = ItemTouchHelper(SwipeToDeleteCallback(listMessageAdapter))
itemTouchHelper.attachToRecyclerView(messagesView)
val swipeRefreshLayout = binding.swipeRefresh
swipeRefreshLayout.setOnRefreshListener { onRefresh() }
binding.drawerLayout.addDrawerListener(
object : SimpleDrawerListener() {
override fun onDrawerOpened(drawerView: View) {
onBackPressedCallback.isEnabled = true
}
override fun onDrawerClosed(drawerView: View) {
updateAppOnDrawerClose?.let { selectApp ->
updateAppOnDrawerClose = null
viewModel.appId = selectApp
launchCoroutine {
updateMessagesForApplication(true, selectApp)
}
invalidateOptionsMenu()
}
onBackPressedCallback.isEnabled = false
}
}
)
swipeRefreshLayout.isEnabled = false
messagesView
.viewTreeObserver
.addOnScrollChangedListener {
val topChild = messagesView.getChildAt(0)
if (topChild != null) {
swipeRefreshLayout.isEnabled = topChild.top == 0
} else {
swipeRefreshLayout.isEnabled = true
}
}
val excludeFromRecent = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(getString(R.string.setting_key_exclude_from_recent), false)
Utils.setExcludeFromRecent(this, excludeFromRecent)
launchCoroutine {
updateMessagesForApplication(true, viewModel.appId)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.learnGotify.setOnClickListener { openDocumentation() }
}
private fun refreshAll() {
CoilInstance.evict(this)
startActivity(Intent(this, InitializationActivity::class.java))
finish()
}
private fun onRefresh() {
CoilInstance.evict(this)
viewModel.messages.clear()
launchCoroutine {
loadMore(viewModel.appId).forEachIndexed { index, message ->
if (message.image != null) {
listMessageAdapter.notifyItemChanged(index)
}
}
}
}
private fun openDocumentation() {
val browserIntent = Intent(Intent.ACTION_VIEW, "https://gotify.net/docs/pushmsg".toUri())
startActivity(browserIntent)
}
private fun onUpdateApps(applications: List<Application>) {
val menu: Menu = binding.navView.menu
menu.removeGroup(R.id.apps)
viewModel.targetReferences.clear()
updateMessagesAndStopLoading(viewModel.messages[viewModel.appId])
var selectedItem = menu.findItem(R.id.nav_all_messages)
applications.indices.forEach { index ->
val app = applications[index]
val item = menu.add(R.id.apps, index, APPLICATION_ORDER, app.name)
item.isCheckable = true
if (app.id == viewModel.appId) selectedItem = item
val t = Utils.toDrawable { icon -> item.icon = icon }
viewModel.targetReferences.add(t)
val request = ImageRequest.Builder(this)
.data(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image))
.error(R.drawable.ic_alarm)
.placeholder(R.drawable.ic_placeholder)
.size(100, 100)
.target(t)
.build()
CoilInstance.get(this).enqueue(request)
}
selectAppInMenu(selectedItem)
}
private fun initDrawer() {
setSupportActionBar(binding.appBarDrawer.toolbar)
binding.navView.itemIconTintList = null
val toggle = ActionBarDrawerToggle(
this,
binding.drawerLayout,
binding.appBarDrawer.toolbar,
R.string.navigation_drawer_open,
R.string.navigation_drawer_close
)
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
val headerView = binding.navView.getHeaderView(0)
val settings = viewModel.settings
val user = headerView.findViewById<TextView>(R.id.header_user)
user.text = settings.user?.name
val connection = headerView.findViewById<TextView>(R.id.header_connection)
connection.text = settings.url
val version = headerView.findViewById<TextView>(R.id.header_version)
version.text =
getString(R.string.versions, BuildConfig.VERSION_NAME, settings.serverVersion)
val refreshAll = headerView.findViewById<ImageButton>(R.id.refresh_all)
refreshAll.setOnClickListener { refreshAll() }
}
private fun addBackPressCallback() {
onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
}
}
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
// Handle navigation view item clicks here.
val id = item.itemId
if (item.groupId == R.id.apps) {
val app = viewModel.appsHolder.get()[id]
updateAppOnDrawerClose = app.id
startLoading()
binding.appBarDrawer.toolbar.subtitle = item.title
} else if (id == R.id.nav_all_messages) {
updateAppOnDrawerClose = MessageState.ALL_MESSAGES
startLoading()
binding.appBarDrawer.toolbar.subtitle = ""
} else if (id == R.id.logout) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.logout)
.setMessage(getString(R.string.logout_confirm))
.setPositiveButton(R.string.yes) { _, _ -> doLogout() }
.setNegativeButton(R.string.cancel, null)
.show()
} else if (id == R.id.nav_logs) {
startActivity(Intent(this, LogsActivity::class.java))
} else if (id == R.id.settings) {
startActivity(Intent(this, SettingsActivity::class.java))
} else if (id == R.id.push_message) {
val intent = Intent(this@MessagesActivity, ShareActivity::class.java)
startActivity(intent)
}
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
}
private fun doLogout() {
setContentView(R.layout.splash)
launchCoroutine {
deleteClientAndNavigateToLogin()
}
}
private fun startLoading() {
binding.swipeRefresh.isRefreshing = true
binding.messagesView.visibility = View.GONE
}
private fun stopLoading() {
binding.swipeRefresh.isRefreshing = false
binding.messagesView.visibility = View.VISIBLE
}
override fun onResume() {
val context = applicationContext
val nManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nManager.cancelAll()
val filter = IntentFilter()
filter.addAction(WebSocketService.NEW_MESSAGE_BROADCAST)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, RECEIVER_EXPORTED)
} else {
@SuppressLint("UnspecifiedRegisterReceiverFlag")
registerReceiver(receiver, filter)
}
launchCoroutine {
updateMissedMessages(viewModel.messages.getLastReceivedMessage())
}
var selectedIndex = R.id.nav_all_messages
val appId = viewModel.appId
if (appId != MessageState.ALL_MESSAGES) {
val apps = viewModel.appsHolder.get()
apps.indices.forEach { index ->
if (apps[index].id == appId) {
selectedIndex = index
}
}
}
// Force re-render of all items to update relative date-times on app resume.
listMessageAdapter.notifyDataSetChanged()
selectAppInMenu(binding.navView.menu.findItem(selectedIndex))
super.onResume()
}
override fun onPause() {
unregisterReceiver(receiver)
super.onPause()
}
private fun selectAppInMenu(appItem: MenuItem?) {
if (appItem != null) {
appItem.isChecked = true
if (appItem.itemId != R.id.nav_all_messages) {
binding.appBarDrawer.toolbar.subtitle = appItem.title
}
}
}
private fun scheduleDeletion(message: Message) {
val adapter = binding.messagesView.adapter as ListMessageAdapter
val messages = viewModel.messages
messages.deleteLocal(message)
adapter.updateList(messages[viewModel.appId])
showDeletionSnackbar()
}
private fun undoDelete() {
val messages = viewModel.messages
val deletion = messages.undoDeleteLocal()
if (deletion != null) {
val adapter = binding.messagesView.adapter as ListMessageAdapter
val appId = viewModel.appId
adapter.updateList(messages[appId])
}
}
private fun showDeletionSnackbar() {
val view: View = binding.swipeRefresh
val snackbar = Snackbar.make(view, R.string.snackbar_deleted, Snackbar.LENGTH_LONG)
snackbar.setAction(R.string.snackbar_undo) { undoDelete() }
snackbar.addCallback(SnackbarCallback())
snackbar.show()
}
private inner class SnackbarCallback : BaseCallback<Snackbar?>() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
super.onDismissed(transientBottomBar, event)
if (event != DISMISS_EVENT_ACTION && event != DISMISS_EVENT_CONSECUTIVE) {
// Execute deletion when the snackbar disappeared without pressing the undo button
// DISMISS_EVENT_CONSECUTIVE should be excluded as well, because it would cause the
// deletion to be sent to the server twice, since the deletion is sent to the server
// in MessageFacade if a message is deleted while another message was already
// waiting for deletion.
launchCoroutine {
commitDeleteMessage()
}
}
}
}
private inner class SwipeToDeleteCallback(private val adapter: ListMessageAdapter) :
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
private var icon: Drawable?
private val background: ColorDrawable
init {
val backgroundColorId =
ContextCompat.getColor(this@MessagesActivity, R.color.swipeBackground)
val iconColorId = ContextCompat.getColor(this@MessagesActivity, R.color.swipeIcon)
val drawable = ContextCompat.getDrawable(this@MessagesActivity, R.drawable.ic_delete)
icon = null
if (drawable != null) {
icon = DrawableCompat.wrap(drawable.mutate())
DrawableCompat.setTint(icon!!, iconColorId)
}
background = backgroundColorId.toDrawable()
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
) = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val message = adapter.currentList[position]
scheduleDeletion(message.message)
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
icon?.let {
val itemView = viewHolder.itemView
val iconHeight = itemView.height / 3
val scale = iconHeight / it.intrinsicHeight.toDouble()
val iconWidth = (it.intrinsicWidth * scale).toInt()
val iconMarginLeftRight = 50
val iconMarginTopBottom = (itemView.height - iconHeight) / 2
val iconTop = itemView.top + iconMarginTopBottom
val iconBottom = itemView.bottom - iconMarginTopBottom
if (dX > 0) {
// Swiping to the right
val iconLeft = itemView.left + iconMarginLeftRight
val iconRight = itemView.left + iconMarginLeftRight + iconWidth
it.setBounds(iconLeft, iconTop, iconRight, iconBottom)
background.setBounds(
itemView.left,
itemView.top,
itemView.left + dX.toInt(),
itemView.bottom
)
} else if (dX < 0) {
// Swiping to the left
val iconLeft = itemView.right - iconMarginLeftRight - iconWidth
val iconRight = itemView.right - iconMarginLeftRight
it.setBounds(iconLeft, iconTop, iconRight, iconBottom)
background.setBounds(
itemView.right + dX.toInt(),
itemView.top,
itemView.right,
itemView.bottom
)
} else {
// View is unswiped
it.setBounds(0, 0, 0, 0)
background.setBounds(0, 0, 0, 0)
}
background.draw(c)
it.draw(c)
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
}
private inner class MessageListOnScrollListener : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(view: RecyclerView, scrollState: Int) {}
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val linearLayoutManager = view.layoutManager as LinearLayoutManager?
if (linearLayoutManager != null) {
val lastVisibleItem = linearLayoutManager.findLastVisibleItemPosition()
val totalItemCount = view.adapter!!.itemCount
if (lastVisibleItem > totalItemCount - 15 &&
totalItemCount != 0 &&
viewModel.messages.canLoadMore(viewModel.appId)
) {
if (!isLoadMore) {
isLoadMore = true
launchCoroutine {
loadMore(viewModel.appId)
}
}
}
}
}
}
private suspend fun updateMissedMessages(id: Long) {
if (id == -1L) return
val newMessages = MissedMessageUtil(viewModel.client.createService(MessageApi::class.java))
.missingMessages(id).filterNotNull()
viewModel.messages.addMessages(newMessages)
if (newMessages.isNotEmpty()) {
updateMessagesForApplication(true, viewModel.appId)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.messages_action, menu)
menu.findItem(R.id.action_delete_app).isVisible =
viewModel.appId != MessageState.ALL_MESSAGES
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_delete_all) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_all)
.setMessage(R.string.ack)
.setPositiveButton(R.string.yes) { _, _ ->
launchCoroutine {
deleteMessages(viewModel.appId)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
if (item.itemId == R.id.action_delete_app) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_app)
.setMessage(R.string.ack)
.setPositiveButton(R.string.yes) { _, _ -> deleteApp(viewModel.appId) }
.setNegativeButton(R.string.no, null)
.show()
}
return super.onContextItemSelected(item)
}
private fun deleteApp(appId: Long) {
val settings = viewModel.settings
val client = ClientFactory.clientToken(settings)
client.createService(ApplicationApi::class.java)
.deleteApp(appId)
.enqueue(
Callback.callInUI(
this,
onSuccess = { refreshAll() },
onError = { Utils.showSnackBar(this, getString(R.string.error_delete_app)) }
)
)
}
private suspend fun loadMore(appId: Long): List<MessageWithImage> {
val messagesWithImages = viewModel.messages.loadMore(appId)
withContext(Dispatchers.Main) {
updateMessagesAndStopLoading(messagesWithImages)
}
return messagesWithImages
}
private suspend fun updateMessagesForApplication(withLoadingSpinner: Boolean, appId: Long) {
if (withLoadingSpinner) {
withContext(Dispatchers.Main) {
startLoading()
}
}
viewModel.messages.loadMoreIfNotPresent(appId)
withContext(Dispatchers.Main) {
updateMessagesAndStopLoading(viewModel.messages[appId])
}
}
private suspend fun addSingleMessage(message: Message) {
viewModel.messages.addMessages(listOf(message))
updateMessagesForApplication(false, viewModel.appId)
}
private suspend fun commitDeleteMessage() {
viewModel.messages.commitDelete()
updateMessagesForApplication(false, viewModel.appId)
}
private suspend fun deleteMessages(appId: Long) {
withContext(Dispatchers.Main) {
startLoading()
}
val success = viewModel.messages.deleteAll(appId)
if (success) {
updateMessagesForApplication(false, viewModel.appId)
} else {
withContext(Dispatchers.Main) {
Utils.showSnackBar(this@MessagesActivity, "Delete failed :(")
}
}
}
private fun deleteClientAndNavigateToLogin() {
val settings = viewModel.settings
val api = ClientFactory.clientToken(settings).createService(ClientApi::class.java)
stopService(Intent(this@MessagesActivity, WebSocketService::class.java))
try {
val clients = Api.execute(api.clients)
var currentClient: Client? = null
for (client in clients) {
if (client.token == settings.token) {
currentClient = client
break
}
}
if (currentClient != null) {
Logger.info("Delete client with id " + currentClient.id)
Api.execute(api.deleteClient(currentClient.id))
} else {
Logger.error("Could not delete client, client does not exist.")
}
} catch (e: ApiException) {
Logger.error(e, "Could not delete client")
}
viewModel.settings.clear()
startActivity(Intent(this@MessagesActivity, LoginActivity::class.java))
finish()
}
private fun updateMessagesAndStopLoading(messageWithImages: List<MessageWithImage>) {
isLoadMore = false
stopLoading()
if (messageWithImages.isEmpty()) {
binding.flipper.displayedChild = 1
} else {
binding.flipper.displayedChild = 0
}
val adapter = binding.messagesView.adapter as ListMessageAdapter
adapter.updateList(messageWithImages)
}
private fun ListMessageAdapter.updateList(list: List<MessageWithImage>) {
this.submitList(if (this.currentList == list) list.toList() else list) {
val topChild = binding.messagesView.getChildAt(0)
if (topChild != null && topChild.top == 0) {
binding.messagesView.scrollToPosition(0)
}
}
}
companion object {
private const val APPLICATION_ORDER = 1
}
}

View File

@@ -0,0 +1,23 @@
package com.github.gotifycustom.messages
import android.app.Activity
import androidx.lifecycle.ViewModel
import coil.target.Target
import com.github.gotifycustom.Settings
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.api.MessageApi
import com.github.gotifycustom.messages.provider.ApplicationHolder
import com.github.gotifycustom.messages.provider.MessageFacade
import com.github.gotifycustom.messages.provider.MessageState
internal class MessagesModel(parentView: Activity) : ViewModel() {
val settings = Settings(parentView)
val client = ClientFactory.clientToken(settings)
val appsHolder = ApplicationHolder(parentView, client)
val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder)
// we need to keep the target references otherwise they get gc'ed before they can be called.
val targetReferences = mutableListOf<Target>()
var appId = MessageState.ALL_MESSAGES
}

View File

@@ -0,0 +1,18 @@
package com.github.gotifycustom.messages
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
internal class MessagesModelFactory(var modelParameterActivity: Activity) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass == MessagesModel::class.java) {
@Suppress("UNCHECKED_CAST")
return modelClass.cast(MessagesModel(modelParameterActivity)) as T
}
throw IllegalArgumentException(
"modelClass parameter must be of type ${MessagesModel::class.java.name}"
)
}
}

View File

@@ -0,0 +1,48 @@
package com.github.gotifycustom.messages.provider
import android.app.Activity
import com.github.gotifycustom.Utils
import com.github.gotifycustom.api.Callback
import com.github.gotify.client.ApiClient
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.client.model.Application
internal class ApplicationHolder(private val activity: Activity, private val client: ApiClient) {
private var state = listOf<Application>()
private var onUpdate: Runnable? = null
private var onUpdateFailed: Runnable? = null
fun wasRequested() = state.isNotEmpty()
fun request() {
client.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.callInUI(
activity,
onSuccess = Callback.SuccessBody { apps -> onReceiveApps(apps) },
onError = { onFailedApps() }
)
)
}
private fun onReceiveApps(apps: List<Application>) {
state = apps
if (onUpdate != null) onUpdate!!.run()
}
private fun onFailedApps() {
Utils.showSnackBar(activity, "Could not request applications, see logs.")
if (onUpdateFailed != null) onUpdateFailed!!.run()
}
fun get() = state
fun onUpdate(runnable: Runnable?) {
onUpdate = runnable
}
fun onUpdateFailed(runnable: Runnable?) {
onUpdateFailed = runnable
}
}

View File

@@ -0,0 +1,5 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.model.Message
internal class MessageDeletion(val message: Message, val allPosition: Int, val appPosition: Int)

View File

@@ -0,0 +1,77 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
internal class MessageFacade(api: MessageApi, private val applicationHolder: ApplicationHolder) {
private val requester = MessageRequester(api)
private val state = MessageStateHolder()
@Synchronized
operator fun get(appId: Long): List<MessageWithImage> {
return MessageImageCombiner.combine(state.state(appId).messages, applicationHolder.get())
}
@Synchronized
fun addMessages(messages: List<Message>) {
messages.forEach {
state.newMessage(it)
}
}
@Synchronized
fun loadMore(appId: Long): List<MessageWithImage> {
val state = state.state(appId)
if (state.hasNext || !state.loaded) {
val pagedMessages = requester.loadMore(state)
if (pagedMessages != null) {
this.state.newMessages(appId, pagedMessages)
}
}
return get(appId)
}
@Synchronized
fun loadMoreIfNotPresent(appId: Long) {
val state = state.state(appId)
if (!state.loaded) {
loadMore(appId)
}
}
@Synchronized
fun clear() {
state.clear()
}
fun getLastReceivedMessage(): Long = state.lastReceivedMessage
@Synchronized
fun deleteLocal(message: Message) {
// If there is already a deletion pending, that one should be executed before scheduling the
// next deletion.
if (state.deletionPending()) commitDelete()
state.deleteMessage(message)
}
@Synchronized
fun commitDelete() {
if (state.deletionPending()) {
val deletion = state.purgePendingDeletion()
requester.asyncRemoveMessage(deletion!!.message)
}
}
@Synchronized
fun undoDeleteLocal(): MessageDeletion? = state.undoPendingDeletion()
@Synchronized
fun deleteAll(appId: Long): Boolean {
val success = requester.deleteAll(appId)
state.deleteAll(appId)
return success
}
@Synchronized
fun canLoadMore(appId: Long): Boolean = state.state(appId).hasNext
}

View File

@@ -0,0 +1,19 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Message
internal object MessageImageCombiner {
fun combine(messages: List<Message>, applications: List<Application>): List<MessageWithImage> {
val appIdToImage = appIdToImage(applications)
return messages.map { MessageWithImage(message = it, image = appIdToImage[it.appid]) }
}
private fun appIdToImage(applications: List<Application>): Map<Long, String> {
val map = mutableMapOf<Long, String>()
applications.forEach {
map[it.id] = it.image
}
return map
}
}

View File

@@ -0,0 +1,49 @@
package com.github.gotifycustom.messages.provider
import com.github.gotifycustom.api.Api
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.Callback
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import com.github.gotify.client.model.PagedMessages
import org.tinylog.kotlin.Logger
internal class MessageRequester(private val messageApi: MessageApi) {
fun loadMore(state: MessageState): PagedMessages? {
return try {
Logger.info("Loading more messages for ${state.appId}")
if (MessageState.ALL_MESSAGES == state.appId) {
Api.execute(messageApi.getMessages(LIMIT, state.nextSince))
} else {
Api.execute(messageApi.getAppMessages(state.appId, LIMIT, state.nextSince))
}
} catch (apiException: ApiException) {
Logger.error(apiException, "failed requesting messages")
null
}
}
fun asyncRemoveMessage(message: Message) {
Logger.info("Removing message with id ${message.id}")
messageApi.deleteMessage(message.id).enqueue(Callback.call())
}
fun deleteAll(appId: Long): Boolean {
return try {
Logger.info("Deleting all messages for $appId")
if (MessageState.ALL_MESSAGES == appId) {
Api.execute(messageApi.deleteMessages())
} else {
Api.execute(messageApi.deleteAppMessages(appId))
}
true
} catch (e: ApiException) {
Logger.error(e, "Could not delete messages")
false
}
}
companion object {
private const val LIMIT = 100
}
}

View File

@@ -0,0 +1,15 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.model.Message
internal class MessageState {
var appId = 0L
var loaded = false
var hasNext = false
var nextSince = 0L
var messages = mutableListOf<Message>()
companion object {
const val ALL_MESSAGES = -1L
}
}

View File

@@ -0,0 +1,131 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.model.Message
import com.github.gotify.client.model.PagedMessages
import kotlin.math.max
internal class MessageStateHolder {
@get:Synchronized
var lastReceivedMessage = -1L
private set
private var states = mutableMapOf<Long, MessageState>()
private var pendingDeletion: MessageDeletion? = null
@Synchronized
fun clear() {
states = mutableMapOf()
}
@Synchronized
fun newMessages(appId: Long, pagedMessages: PagedMessages) {
val state = state(appId)
if (!state.loaded && pagedMessages.messages.size > 0) {
lastReceivedMessage = max(pagedMessages.messages[0].id, lastReceivedMessage)
}
state.apply {
loaded = true
messages.addAll(pagedMessages.messages)
hasNext = pagedMessages.paging.next != null
nextSince = pagedMessages.paging.since
this.appId = appId
}
states[appId] = state
// If there is a message with pending deletion, it should not reappear in the list in case
// it is added again.
if (deletionPending()) {
deleteMessage(pendingDeletion!!.message)
}
}
@Synchronized
fun newMessage(message: Message) {
// If there is a message with pending deletion, its indices are going to change. To keep
// them consistent the deletion is undone first and redone again after adding the new
// message.
val deletion = undoPendingDeletion()
addMessage(message, 0, 0)
lastReceivedMessage = message.id
if (deletion != null) deleteMessage(deletion.message)
}
@Synchronized
fun state(appId: Long): MessageState = states[appId] ?: emptyState(appId)
@Synchronized
fun deleteAll(appId: Long) {
clear()
val state = state(appId)
state.loaded = true
states[appId] = state
}
private fun emptyState(appId: Long): MessageState {
return MessageState().apply {
loaded = false
hasNext = false
nextSince = 0
this.appId = appId
}
}
@Synchronized
fun deleteMessage(message: Message) {
val allMessages = state(MessageState.ALL_MESSAGES)
val appMessages = state(message.appid)
var pendingDeletedAllPosition = -1
var pendingDeletedAppPosition = -1
if (allMessages.loaded) {
val allPosition = allMessages.messages.indexOf(message)
if (allPosition != -1) allMessages.messages.removeAt(allPosition)
pendingDeletedAllPosition = allPosition
}
if (appMessages.loaded) {
val appPosition = appMessages.messages.indexOf(message)
if (appPosition != -1) appMessages.messages.removeAt(appPosition)
pendingDeletedAppPosition = appPosition
}
pendingDeletion = MessageDeletion(
message,
pendingDeletedAllPosition,
pendingDeletedAppPosition
)
}
@Synchronized
fun undoPendingDeletion(): MessageDeletion? {
if (pendingDeletion != null) {
addMessage(
pendingDeletion!!.message,
pendingDeletion!!.allPosition,
pendingDeletion!!.appPosition
)
}
return purgePendingDeletion()
}
@Synchronized
fun purgePendingDeletion(): MessageDeletion? {
val result = pendingDeletion
pendingDeletion = null
return result
}
@Synchronized
fun deletionPending(): Boolean = pendingDeletion != null
private fun addMessage(message: Message, allPosition: Int, appPosition: Int) {
val allMessages = state(MessageState.ALL_MESSAGES)
val appMessages = state(message.appid)
if (allMessages.loaded && allPosition != -1) {
allMessages.messages.add(allPosition, message)
}
if (appMessages.loaded && appPosition != -1) {
appMessages.messages.add(appPosition, message)
}
}
}

View File

@@ -0,0 +1,5 @@
package com.github.gotifycustom.messages.provider
import com.github.gotify.client.model.Message
internal data class MessageWithImage(val message: Message, val image: String?)

View File

@@ -0,0 +1,233 @@
package com.github.gotifycustom.service
import android.app.AlarmManager
import android.app.AlarmManager.OnAlarmListener
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.github.gotifycustom.SSLSettings
import com.github.gotifycustom.Utils
import com.github.gotifycustom.api.CertUtils
import com.github.gotify.client.model.Message
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.tinylog.kotlin.Logger
internal class WebSocketConnection(
private val baseUrl: String,
settings: SSLSettings,
private val token: String?,
private val alarmManager: AlarmManager
) {
companion object {
private val ID = AtomicLong(0)
}
private var alarmManagerCallback: OnAlarmListener? = null
private var handlerCallback: Runnable? = null
private val client: OkHttpClient
private val reconnectHandler = Handler(Looper.getMainLooper())
private var errorCount = 0
private var webSocket: WebSocket? = null
private lateinit var onMessageCallback: (Message) -> Unit
private lateinit var onClose: Runnable
private lateinit var onOpen: Runnable
private lateinit var onFailure: OnNetworkFailureRunnable
private lateinit var onReconnected: Runnable
private var state: State? = null
init {
val builder = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(1, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS)
CertUtils.applySslSettings(builder, settings)
client = builder.build()
}
@Synchronized
fun onMessage(onMessage: (Message) -> Unit): WebSocketConnection {
this.onMessageCallback = onMessage
return this
}
@Synchronized
fun onClose(onClose: Runnable): WebSocketConnection {
this.onClose = onClose
return this
}
@Synchronized
fun onOpen(onOpen: Runnable): WebSocketConnection {
this.onOpen = onOpen
return this
}
@Synchronized
fun onFailure(onFailure: OnNetworkFailureRunnable): WebSocketConnection {
this.onFailure = onFailure
return this
}
@Synchronized
fun onReconnected(onReconnected: Runnable): WebSocketConnection {
this.onReconnected = onReconnected
return this
}
private fun request(): Request {
val url = baseUrl.toHttpUrlOrNull()!!
.newBuilder()
.addPathSegment("stream")
.addQueryParameter("token", token)
.build()
return Request.Builder().url(url).get().build()
}
@Synchronized
fun start(): WebSocketConnection {
if (state == State.Connecting || state == State.Connected) {
return this
}
close()
state = State.Connecting
val nextId = ID.incrementAndGet()
Logger.info("WebSocket($nextId): starting...")
webSocket = client.newWebSocket(request(), Listener(nextId))
return this
}
@Synchronized
fun close() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
alarmManagerCallback?.run(alarmManager::cancel)
alarmManagerCallback = null
} else {
handlerCallback?.run(reconnectHandler::removeCallbacks)
handlerCallback = null
}
if (webSocket != null) {
webSocket?.close(1000, "")
closed()
Logger.info("WebSocket(${ID.get()}): closing existing connection.")
}
}
@Synchronized
private fun closed() {
webSocket = null
state = State.Disconnected
}
fun scheduleReconnectNow(seconds: Long) = scheduleReconnect(ID.get(), seconds)
@Synchronized
fun scheduleReconnect(id: Long, seconds: Long) {
if (state == State.Connecting || state == State.Connected) {
return
}
state = State.Scheduled
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Logger.info("WebSocket: scheduling a restart in $seconds second(s) (via alarm manager)")
val future = Calendar.getInstance()
future.add(Calendar.SECOND, seconds.toInt())
alarmManagerCallback?.run(alarmManager::cancel)
val cb = OnAlarmListener { syncExec(id) { start() } }
alarmManagerCallback = cb
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
future.timeInMillis,
"reconnect-tag",
cb,
null
)
} else {
Logger.info("WebSocket: scheduling a restart in $seconds second(s)")
handlerCallback?.run(reconnectHandler::removeCallbacks)
val cb = Runnable { syncExec(id) { start() } }
handlerCallback = cb
reconnectHandler.postDelayed(cb, TimeUnit.SECONDS.toMillis(seconds))
}
}
private inner class Listener(private val id: Long) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
syncExec(id) {
state = State.Connected
Logger.info("WebSocket($id): opened")
onOpen.run()
if (errorCount > 0) {
onReconnected.run()
errorCount = 0
}
}
super.onOpen(webSocket, response)
}
override fun onMessage(webSocket: WebSocket, text: String) {
syncExec(id) {
Logger.info("WebSocket($id): received message $text")
val message = Utils.JSON.fromJson(text, Message::class.java)
onMessageCallback(message)
}
super.onMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
syncExec(id) {
if (state == State.Connected) {
Logger.warn("WebSocket($id): closed")
onClose.run()
}
closed()
}
super.onClosed(webSocket, code, reason)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val code = if (response != null) "StatusCode: ${response.code}" else ""
val message = response?.message ?: ""
Logger.error(t) { "WebSocket($id): failure $code Message: $message" }
syncExec(id) {
closed()
errorCount++
val minutes = (errorCount * 2 - 1).coerceAtMost(20)
onFailure.execute(response?.message ?: "unreachable", minutes)
scheduleReconnect(id, TimeUnit.MINUTES.toSeconds(minutes.toLong()))
}
super.onFailure(webSocket, t, response)
}
}
@Synchronized
private fun syncExec(id: Long, runnable: () -> Unit) {
if (ID.get() == id) {
runnable()
}
}
internal fun interface OnNetworkFailureRunnable {
fun execute(status: String, minutes: Int)
}
internal enum class State {
Scheduled,
Connecting,
Connected,
Disconnected
}
}

View File

@@ -0,0 +1,463 @@
package com.github.gotifycustom.service
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import com.github.gotifycustom.BuildConfig
import com.github.gotifycustom.CoilInstance
import com.github.gotifycustom.MarkwonFactory
import com.github.gotifycustom.MissedMessageUtil
import com.github.gotifycustom.NotificationSupport
import com.github.gotifycustom.R
import com.github.gotifycustom.Settings
import com.github.gotifycustom.Utils
import com.github.gotifycustom.api.Callback
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Message
import com.github.gotifycustom.log.LoggerHelper
import com.github.gotifycustom.log.UncaughtExceptionHandler
import com.github.gotifycustom.messages.Extras
import com.github.gotifycustom.messages.IntentUrlDialogActivity
import com.github.gotifycustom.messages.MessagesActivity
import io.noties.markwon.Markwon
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import org.tinylog.kotlin.Logger
internal class WebSocketService : Service() {
companion object {
private val castAddition = if (BuildConfig.DEBUG) ".DEBUG" else ""
val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE$castAddition"
private const val NOT_LOADED = -2L
}
private lateinit var settings: Settings
private var connection: WebSocketConnection? = null
private val networkCallback: ConnectivityManager.NetworkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Logger.info("WebSocket: Network available, reconnect if needed.")
connection?.start()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
super.onLinkPropertiesChanged(network, linkProperties)
Logger.info("WebSocket: Network properties changed, reconnect if needed.")
connection?.start()
}
}
private val appIdToApp = ConcurrentHashMap<Long, Application>()
private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil
private lateinit var markwon: Markwon
override fun onCreate() {
super.onCreate()
settings = Settings(this)
val client = ClientFactory.clientToken(settings)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Logger.info("Create ${javaClass.simpleName}")
markwon = MarkwonFactory.createForNotification(this, CoilInstance.get(this))
}
override fun onDestroy() {
super.onDestroy()
connection?.close()
Logger.warn("Destroy ${javaClass.simpleName}")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LoggerHelper.init(this)
UncaughtExceptionHandler.registerCurrentThread()
connection?.close()
Logger.info("Starting ${javaClass.simpleName}")
super.onStartCommand(intent, flags, startId)
Thread { startPushService() }.start()
return START_STICKY
}
private fun startPushService() {
UncaughtExceptionHandler.registerCurrentThread()
showForegroundNotification(getString(R.string.websocket_init))
if (lastReceivedMessage.get() == NOT_LOADED) {
missingMessageUtil.lastReceivedMessage { lastReceivedMessage.set(it) }
}
val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
connection = WebSocketConnection(
settings.url,
settings.sslSettings(),
settings.token,
alarmManager
)
.onOpen { onOpen() }
.onClose { onClose() }
.onFailure { status, minutes -> onFailure(status, minutes) }
.onMessage { message -> onMessage(message) }
.onReconnected { notifyMissedNotifications() }
.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
cm.registerDefaultNetworkCallback(networkCallback)
}
fetchApps()
}
private fun fetchApps() {
ClientFactory.clientToken(settings)
.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.call(
onSuccess = Callback.SuccessBody { apps ->
appIdToApp.clear()
appIdToApp.putAll(apps.associateBy { it.id })
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createChannels(
this,
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager),
apps
)
}
},
onError = { appIdToApp.clear() }
)
)
}
private fun onClose() {
showForegroundNotification(
getString(R.string.websocket_closed),
getString(R.string.websocket_reconnect)
)
ClientFactory.userApiWithToken(settings)
.currentUser()
.enqueue(
Callback.call(
onSuccess = { doReconnect() },
onError = { exception ->
if (exception.code == 401) {
showForegroundNotification(
getString(R.string.user_action),
getString(R.string.websocket_closed_logout)
)
} else {
Logger.info(
"WebSocket closed but the user still authenticated, " +
"trying to reconnect"
)
doReconnect()
}
}
)
)
}
private fun doReconnect() {
connection?.scheduleReconnectNow(15)
}
private fun onFailure(status: String, minutes: Int) {
val title = getString(R.string.websocket_error, status)
val intervalUnit = resources
.getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes)
showForegroundNotification(
title,
"${getString(R.string.websocket_reconnect)} $intervalUnit"
)
}
private fun onOpen() {
showForegroundNotification(getString(R.string.websocket_listening))
}
private fun notifyMissedNotifications() {
val messageId = lastReceivedMessage.get()
if (messageId == NOT_LOADED) {
return
}
val messages = missingMessageUtil.missingMessages(messageId).filterNotNull()
if (messages.size > 5) {
onGroupedMessages(messages)
} else {
messages.forEach {
onMessage(it)
}
}
}
private fun onGroupedMessages(messages: List<Message>) {
var highestPriority = 0L
messages.forEach { message ->
if (lastReceivedMessage.get() < message.id) {
lastReceivedMessage.set(message.id)
highestPriority = highestPriority.coerceAtLeast(message.priority)
}
broadcast(message)
}
if (settings.shouldNotify(highestPriority)) {
val size = messages.size
showNotification(
NotificationSupport.ID.GROUPED,
getString(R.string.missed_messages),
getString(R.string.grouped_message, size),
highestPriority,
null
)
}
}
private fun onMessage(message: Message) {
if (lastReceivedMessage.get() < message.id) {
lastReceivedMessage.set(message.id)
}
broadcast(message)
if (settings.shouldNotify(message.priority)) {
showNotification(
message.id,
message.title,
message.message,
message.priority,
message.extras,
message.appid
)
}
}
private fun broadcast(message: Message) {
val intent = Intent()
intent.action = NEW_MESSAGE_BROADCAST
intent.putExtra("message", Utils.JSON.toJson(message))
sendBroadcast(intent)
}
override fun onBind(intent: Intent): IBinder? = null
private fun showForegroundNotification(title: String, message: String? = null) {
val notificationIntent = Intent(this, MessagesActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder =
NotificationCompat.Builder(this, NotificationSupport.Channel.FOREGROUND)
notificationBuilder.setSmallIcon(R.drawable.ic_gotify)
notificationBuilder.setOngoing(true)
notificationBuilder.priority = NotificationCompat.PRIORITY_MIN
notificationBuilder.setShowWhen(false)
notificationBuilder.setWhen(0)
notificationBuilder.setContentTitle(title)
if (message != null) {
notificationBuilder.setContentText(message)
notificationBuilder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
notificationBuilder.setContentIntent(pendingIntent)
notificationBuilder.color = ContextCompat.getColor(applicationContext, R.color.colorPrimary)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NotificationSupport.ID.FOREGROUND,
notificationBuilder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(NotificationSupport.ID.FOREGROUND, notificationBuilder.build())
}
}
private fun showNotification(
id: Int,
title: String,
message: String,
priority: Long,
extras: Map<String, Any>?
) {
showNotification(id.toLong(), title, message, priority, extras, -1L)
}
private fun showNotification(
id: Long,
title: String,
message: String,
priority: Long,
extras: Map<String, Any>?,
appId: Long
) {
var intent: Intent
val intentUrl = Extras.getNestedValue(
String::class.java,
extras,
"android::action",
"onReceive",
"intentUrl"
)
if (intentUrl != null) {
val prompt = PreferenceManager.getDefaultSharedPreferences(this).getBoolean(
getString(R.string.setting_key_prompt_onreceive_intent),
resources.getBoolean(R.bool.prompt_onreceive_intent)
)
val onReceiveIntent = if (prompt) {
Intent(this, IntentUrlDialogActivity::class.java).apply {
putExtra(IntentUrlDialogActivity.EXTRA_KEY_URL, intentUrl)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
} else {
Intent(Intent.ACTION_VIEW).apply {
data = intentUrl.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
startActivity(onReceiveIntent)
}
val url = Extras.getNestedValue(
String::class.java,
extras,
"client::notification",
"click",
"url"
)
if (url != null) {
intent = Intent(Intent.ACTION_VIEW)
intent.data = url.toUri()
} else {
intent = Intent(this, MessagesActivity::class.java)
}
val contentIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val channelId: String
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
NotificationSupport.areAppChannelsRequested(this)
) {
channelId = NotificationSupport.getChannelID(priority, appId.toString())
NotificationSupport.createChannelIfNonexistent(
this,
appId.toString(),
channelId
)
} else {
channelId = NotificationSupport.convertPriorityToChannel(priority)
}
val b = NotificationCompat.Builder(this, channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
showNotificationGroup(channelId)
}
b.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(CoilInstance.getIcon(this, appIdToApp[appId]))
.setTicker("${getString(R.string.app_name)} - $title")
.setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title)
.setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND)
.setLights(Color.CYAN, 1000, 5000)
.setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary))
.setContentIntent(contentIntent)
var formattedMessage = message as CharSequence
var newMessage: String? = null
if (Extras.useMarkdown(extras)) {
formattedMessage = markwon.toMarkdown(message)
newMessage = formattedMessage.toString()
}
b.setContentText(newMessage ?: message)
b.setStyle(NotificationCompat.BigTextStyle().bigText(formattedMessage))
val notificationImageUrl = Extras.getNestedValue(
String::class.java,
extras,
"client::notification",
"bigImageUrl"
)
if (notificationImageUrl != null) {
try {
b.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(CoilInstance.getImageFromUrl(this, notificationImageUrl))
)
} catch (e: Exception) {
Logger.error(e, "Error loading bigImageUrl")
}
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(Utils.longToInt(id), b.build())
}
@RequiresApi(Build.VERSION_CODES.N)
fun showNotificationGroup(channelId: String) {
val intent = Intent(this, MessagesActivity::class.java)
val contentIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(
this,
channelId
)
builder.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setTicker(getString(R.string.app_name))
.setGroup(NotificationSupport.Group.MESSAGES)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle(getString(R.string.grouped_notification_text))
.setGroupSummary(true)
.setContentText(getString(R.string.grouped_notification_text))
.setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary))
.setContentIntent(contentIntent)
val notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(-5, builder.build())
}
}

View File

@@ -0,0 +1,226 @@
package com.github.gotifycustom.settings
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.preference.ListPreference
import androidx.preference.ListPreferenceDialogFragmentCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import com.github.gotifycustom.R
import com.github.gotifycustom.Utils
import com.github.gotifycustom.databinding.SettingsActivityBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
internal class SettingsActivity :
AppCompatActivity(),
OnSharedPreferenceChangeListener {
private lateinit var binding: SettingsActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = SettingsActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment())
.commit()
setSupportActionBar(binding.appBarDrawer.toolbar)
val actionBar = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowCustomEnabled(true)
}
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return false
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (sharedPreferences == null) return
when (key) {
getString(R.string.setting_key_theme) -> {
ThemeHelper.setTheme(
this,
sharedPreferences.getString(key, getString(R.string.theme_default))!!
)
}
}
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_notification_channels)
)?.isEnabled = true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
findPreference<ListPreference>(
getString(R.string.setting_key_message_layout)
)?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
showRestartDialog()
true
}
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_notification_channels)
)?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return@OnPreferenceChangeListener false
}
showRestartDialog()
true
}
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_exclude_from_recent)
)?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value ->
Utils.setExcludeFromRecent(requireContext(), value as Boolean)
return@OnPreferenceChangeListener true
}
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_intent_dialog_permission)
)?.let {
it.setOnPreferenceChangeListener { _, _ ->
openSystemAlertWindowPermissionPage()
}
}
checkSystemAlertWindowPermission()
}
override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is ListPreference) {
showListPreferenceDialog(preference)
} else {
super.onDisplayPreferenceDialog(preference)
}
}
override fun onResume() {
super.onResume()
checkSystemAlertWindowPermission()
}
private fun openSystemAlertWindowPermissionPage(): Boolean {
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${requireContext().packageName}".toUri()
).apply {
startActivity(this)
}
return true
}
private fun checkSystemAlertWindowPermission() {
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_intent_dialog_permission)
)?.let {
val canDrawOverlays = Settings.canDrawOverlays(requireContext())
it.isChecked = canDrawOverlays
it.summary = if (canDrawOverlays) {
getString(R.string.setting_summary_intent_dialog_permission_granted)
} else {
getString(R.string.setting_summary_intent_dialog_permission)
}
}
}
private fun showListPreferenceDialog(preference: ListPreference) {
val dialogFragment = MaterialListPreference()
dialogFragment.arguments = Bundle(1).apply { putString("key", preference.key) }
@Suppress("DEPRECATION") // https://issuetracker.google.com/issues/181793702#comment3
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(
parentFragmentManager,
"androidx.preference.PreferenceFragment.DIALOG"
)
}
private fun showRestartDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.setting_restart_dialog_title)
.setMessage(R.string.setting_restart_dialog_message)
.setPositiveButton(getString(R.string.setting_restart_dialog_button1)) { _, _ ->
restartApp()
}
.setNegativeButton(getString(R.string.setting_restart_dialog_button2), null)
.show()
}
private fun restartApp() {
val packageManager = requireContext().packageManager
val packageName = requireContext().packageName
val intent = packageManager.getLaunchIntentForPackage(packageName)
val componentName = intent!!.component
val mainIntent = Intent.makeRestartActivityTask(componentName)
startActivity(mainIntent)
Runtime.getRuntime().exit(0)
}
}
class MaterialListPreference : ListPreferenceDialogFragmentCompat() {
private var mWhichButtonClicked = 0
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
mWhichButtonClicked = DialogInterface.BUTTON_NEGATIVE
val builder = MaterialAlertDialogBuilder(requireActivity())
.setTitle(preference.dialogTitle)
.setPositiveButton(preference.positiveButtonText, this)
.setNegativeButton(preference.negativeButtonText, this)
val contentView = context?.let { onCreateDialogView(it) }
if (contentView != null) {
onBindDialogView(contentView)
builder.setView(contentView)
} else {
builder.setMessage(preference.dialogMessage)
}
onPrepareDialogBuilder(builder)
return builder.create()
}
override fun onClick(dialog: DialogInterface, which: Int) {
mWhichButtonClicked = which
}
override fun onDismiss(dialog: DialogInterface) {
onDialogClosedWasCalledFromOnDismiss = true
super.onDismiss(dialog)
}
private var onDialogClosedWasCalledFromOnDismiss = false
override fun onDialogClosed(positiveResult: Boolean) {
if (onDialogClosedWasCalledFromOnDismiss) {
onDialogClosedWasCalledFromOnDismiss = false
super.onDialogClosed(mWhichButtonClicked == DialogInterface.BUTTON_POSITIVE)
} else {
super.onDialogClosed(positiveResult)
}
}
}
}

View File

@@ -0,0 +1,24 @@
package com.github.gotifycustom.settings
import android.content.Context
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import com.github.gotifycustom.R
internal object ThemeHelper {
fun setTheme(context: Context, newTheme: String) {
AppCompatDelegate.setDefaultNightMode(ofKey(context, newTheme))
}
private fun ofKey(context: Context, newTheme: String): Int {
return if (context.getString(R.string.theme_dark) == newTheme) {
AppCompatDelegate.MODE_NIGHT_YES
} else if (context.getString(R.string.theme_light) == newTheme) {
AppCompatDelegate.MODE_NIGHT_NO
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
} else {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}

View File

@@ -0,0 +1,155 @@
package com.github.gotifycustom.sharing
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Spinner
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.github.gotifycustom.R
import com.github.gotifycustom.Settings
import com.github.gotifycustom.Utils.launchCoroutine
import com.github.gotifycustom.api.Api
import com.github.gotifycustom.api.ApiException
import com.github.gotifycustom.api.ClientFactory
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Message
import com.github.gotifycustom.databinding.ActivityShareBinding
import com.github.gotifycustom.messages.provider.ApplicationHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tinylog.kotlin.Logger
internal class ShareActivity : AppCompatActivity() {
private lateinit var binding: ActivityShareBinding
private lateinit var settings: Settings
private lateinit var appsHolder: ApplicationHolder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShareBinding.inflate(layoutInflater)
setContentView(binding.root)
Logger.info("Entering ${javaClass.simpleName}")
setSupportActionBar(binding.appBarDrawer.toolbar)
val actionBar = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowCustomEnabled(true)
}
settings = Settings(this)
val intent = intent
val type = intent.type
if (Intent.ACTION_SEND == intent.action && "text/plain" == type) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
if (sharedText != null) {
binding.content.setText(sharedText)
}
}
if (!settings.tokenExists()) {
Toast.makeText(
applicationContext,
R.string.not_loggedin_share,
Toast.LENGTH_SHORT
).show()
finish()
return
}
val client = ClientFactory.clientToken(settings)
appsHolder = ApplicationHolder(this, client)
appsHolder.onUpdate {
val apps = appsHolder.get()
populateSpinner(apps)
val appsAvailable = apps.isNotEmpty()
binding.pushButton.isEnabled = appsAvailable
binding.missingAppsContainer.visibility = if (appsAvailable) View.GONE else View.VISIBLE
}
appsHolder.onUpdateFailed { binding.pushButton.isEnabled = false }
appsHolder.request()
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.pushButton.setOnClickListener { pushMessage() }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return false
}
private fun pushMessage() {
val titleText = binding.title.text.toString()
val contentText = binding.content.text.toString()
val priority = binding.edtTxtPriority.text.toString()
val appIndex = binding.appSpinner.selectedItemPosition
if (contentText.isEmpty()) {
Toast.makeText(this, "Content should not be empty.", Toast.LENGTH_LONG).show()
return
} else if (priority.isEmpty()) {
Toast.makeText(this, "Priority should be number.", Toast.LENGTH_LONG).show()
return
} else if (appIndex == Spinner.INVALID_POSITION) {
// For safety, e.g. loading the apps needs too much time (maybe a timeout) and
// the user tries to push without an app selected.
Toast.makeText(this, "An app must be selected.", Toast.LENGTH_LONG).show()
return
}
val message = Message()
if (titleText.isNotEmpty()) {
message.title = titleText
}
message.message = contentText
message.priority = priority.toLong()
launchCoroutine {
val response = executeMessageCall(appIndex, message)
withContext(Dispatchers.Main) {
if (response) {
Toast.makeText(this@ShareActivity, "Pushed!", Toast.LENGTH_LONG).show()
finish()
} else {
Toast.makeText(
this@ShareActivity,
"Oops! Something went wrong...",
Toast.LENGTH_LONG
).show()
}
}
}
}
private fun executeMessageCall(appIndex: Int, message: Message): Boolean {
val pushClient = ClientFactory.clientToken(settings, appsHolder.get()[appIndex].token)
return try {
val messageApi = pushClient.createService(MessageApi::class.java)
Api.execute(messageApi.createMessage(message))
true
} catch (apiException: ApiException) {
Logger.error(apiException, "Failed sending message")
false
}
}
private fun populateSpinner(apps: List<Application>) {
val appNameList = mutableListOf<String>()
apps.forEach {
appNameList.add(it.name)
}
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, appNameList)
binding.appSpinner.adapter = adapter
}
}