Implement priority filtering, rename package, preset URL, update remotes
Some checks failed
Build / Check (push) Has been cancelled
Some checks failed
Build / Check (push) Has been cancelled
This commit is contained in:
157
app/src/main/kotlin/com/github/gotifycustom/CoilInstance.kt
Normal file
157
app/src/main/kotlin/com/github/gotifycustom/CoilInstance.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
126
app/src/main/kotlin/com/github/gotifycustom/MarkwonFactory.kt
Normal file
126
app/src/main/kotlin/com/github/gotifycustom/MarkwonFactory.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.github.gotifycustom
|
||||
|
||||
internal data class SSLSettings(
|
||||
val validateSSL: Boolean,
|
||||
val caCertPath: String?,
|
||||
val clientCertPath: String?,
|
||||
val clientCertPassword: String?
|
||||
)
|
||||
89
app/src/main/kotlin/com/github/gotifycustom/Settings.kt
Normal file
89
app/src/main/kotlin/com/github/gotifycustom/Settings.kt
Normal 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
|
||||
}
|
||||
105
app/src/main/kotlin/com/github/gotifycustom/Utils.kt
Normal file
105
app/src/main/kotlin/com/github/gotifycustom/Utils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/src/main/kotlin/com/github/gotifycustom/api/Api.kt
Normal file
34
app/src/main/kotlin/com/github/gotifycustom/api/Api.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}"
|
||||
}
|
||||
85
app/src/main/kotlin/com/github/gotifycustom/api/Callback.kt
Normal file
85
app/src/main/kotlin/com/github/gotifycustom/api/Callback.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/src/main/kotlin/com/github/gotifycustom/api/CertUtils.kt
Normal file
127
app/src/main/kotlin/com/github/gotifycustom/api/CertUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user