Move source files from 'java' to 'kotlin' folder

This commit is contained in:
Niko Diamadis
2023-01-13 14:49:53 +01:00
parent 24812b6f43
commit 3321f9eee5
39 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
package com.github.gotify
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 com.squareup.picasso.Picasso
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.picasso.PicassoImagesPlugin
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
internal object MarkwonFactory {
fun createForMessage(context: Context, picasso: Picasso): Markwon {
return Markwon.builder(context)
.usePlugin(CorePlugin.create())
.usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create()))
.usePlugin(PicassoImagesPlugin.create(picasso))
.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, picasso: Picasso): 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(PicassoImagesPlugin.create(picasso))
.usePlugin(StrikethroughPlugin.create())
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
builder.setFactory(Heading::class.java) { _, props: RenderProps? ->
arrayOf<Any>(
RelativeSizeSpan(
headingSizes[CoreProps.HEADING_LEVEL.require(props!!) - 1]
),
StyleSpan(Typeface.BOLD)
)
}
.setFactory(Emphasis::class.java) { _, _ -> StyleSpan(Typeface.ITALIC) }
.setFactory(StrongEmphasis::class.java) { _, _ -> StyleSpan(Typeface.BOLD) }
.setFactory(BlockQuote::class.java) { _, _ -> QuoteSpan() }
.setFactory(Code::class.java) { _, _ ->
arrayOf<Any>(
BackgroundColorSpan(Color.LTGRAY),
TypefaceSpan("monospace")
)
}
.setFactory(ListItem::class.java) { _, _ -> BulletSpan(bulletGapWidth) }
.setFactory(Link::class.java) { _, _ -> null }
}
override fun configureParser(builder: Parser.Builder) {
builder.extensions(setOf(TablesExtension.create()))
}
override fun configureVisitor(builder: MarkwonVisitor.Builder) {
builder.on(TableCell::class.java) { visitor: MarkwonVisitor, node: TableCell? ->
visitor.visitChildren(node!!)
visitor.builder().append(' ')
}
}
})
.build()
}
}

View File

@@ -0,0 +1,65 @@
package com.github.gotify
import com.github.gotify.api.Api
import com.github.gotify.api.ApiException
import com.github.gotify.api.Callback
import com.github.gotify.api.Callback.SuccessCallback
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
internal class MissedMessageUtil(private val api: MessageApi) {
fun lastReceivedMessage(successCallback: SuccessCallback<Long>) {
api.getMessages(1, 0L).enqueue(
Callback.call(
onSuccess = { messages ->
if (messages.messages.size == 1) {
successCallback.onSuccess(messages.messages[0].id)
} else {
successCallback.onSuccess(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.size == 0 ||
pagedMessages.paging.next == null
) {
break
}
since = pagedMessages.paging.since
}
} catch (e: ApiException) {
Log.e("cannot retrieve missing messages", e)
}
return result.reversed()
}
private fun filter(messages: List<Message>, till: Long): List<Message?> {
val result = mutableListOf<Message?>()
for (message in messages) {
if (message.id > till) {
result.add(message)
} else {
break
}
}
return result
}
companion object {
const val NO_MESSAGES = 0L
}
}

View File

@@ -0,0 +1,106 @@
package com.github.gotify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.graphics.Color
import android.os.Build
import androidx.annotation.RequiresApi
import com.github.gotify.log.Log
internal object NotificationSupport {
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(notificationManager: NotificationManager) {
try {
// 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,
"Gotify foreground notification",
NotificationManager.IMPORTANCE_LOW
)
foreground.setShowBadge(false)
val messagesImportanceMin = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_MIN,
"Min priority messages (<1)",
NotificationManager.IMPORTANCE_MIN
)
val messagesImportanceLow = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_LOW,
"Low priority messages (1-3)",
NotificationManager.IMPORTANCE_LOW
)
val messagesImportanceDefault = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_DEFAULT,
"Normal priority messages (4-7)",
NotificationManager.IMPORTANCE_DEFAULT
)
messagesImportanceDefault.enableLights(true)
messagesImportanceDefault.lightColor = Color.CYAN
messagesImportanceDefault.enableVibration(true)
val messagesImportanceHigh = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_HIGH,
"High priority messages (>7)",
NotificationManager.IMPORTANCE_HIGH
)
messagesImportanceHigh.enableLights(true)
messagesImportanceHigh.lightColor = Color.CYAN
messagesImportanceHigh.enableVibration(true)
notificationManager.createNotificationChannel(foreground)
notificationManager.createNotificationChannel(messagesImportanceMin)
notificationManager.createNotificationChannel(messagesImportanceLow)
notificationManager.createNotificationChannel(messagesImportanceDefault)
notificationManager.createNotificationChannel(messagesImportanceHigh)
} catch (e: Exception) {
Log.e("Could not create channel", e)
}
}
/**
* 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
}
}
object Group {
const val MESSAGES = "GOTIFY_GROUP_MESSAGES"
}
object Channel {
const val FOREGROUND = "gotify_foreground"
const val MESSAGES_IMPORTANCE_MIN = "gotify_messages_min_importance"
const val MESSAGES_IMPORTANCE_LOW = "gotify_messages_low_importance"
const val MESSAGES_IMPORTANCE_DEFAULT = "gotify_messages_default_importance"
const val MESSAGES_IMPORTANCE_HIGH = "gotify_messages_high_importance"
}
object ID {
const val FOREGROUND = -1
const val GROUPED = -2
}
}

View File

@@ -0,0 +1,3 @@
package com.github.gotify
internal class SSLSettings(val validateSSL: Boolean, val cert: String?)

View File

@@ -0,0 +1,56 @@
package com.github.gotify
import android.content.Context
import android.content.SharedPreferences
import com.github.gotify.client.model.User
internal class Settings(context: Context) {
private val sharedPreferences: SharedPreferences
var url: String
get() = sharedPreferences.getString("url", "")!!
set(value) = sharedPreferences.edit().putString("url", value).apply()
var token: String?
get() = sharedPreferences.getString("token", null)
set(value) = sharedPreferences.edit().putString("token", value).apply()
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).apply()
var cert: String?
get() = sharedPreferences.getString("cert", null)
set(value) = sharedPreferences.edit().putString("cert", value).apply()
var validateSSL: Boolean
get() = sharedPreferences.getBoolean("validateSSL", true)
set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply()
init {
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
}
fun tokenExists(): Boolean = !token.isNullOrEmpty()
fun clear() {
url = ""
token = null
validateSSL = true
cert = null
}
fun setUser(name: String?, admin: Boolean) {
sharedPreferences.edit().putString("username", name).putBoolean("admin", admin).apply()
}
fun sslSettings(): SSLSettings {
return SSLSettings(validateSSL, cert)
}
}

View File

@@ -0,0 +1,118 @@
package com.github.gotify
import android.app.Activity
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.text.format.DateUtils
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.github.gotify.client.JSON
import com.github.gotify.log.Log
import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson
import com.squareup.picasso.Picasso.LoadedFrom
import com.squareup.picasso.Target
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okio.Buffer
import org.threeten.bp.OffsetDateTime
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
import java.net.URL
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) {
Log.e("Could not resolve absolute url", e)
target
} catch (e: URISyntaxException) {
Log.e("Could not resolve absolute url", e)
target
}
}
}
fun toDrawable(resources: Resources?, drawableReceiver: DrawableReceiver): Target {
return object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: LoadedFrom) {
drawableReceiver.loaded(BitmapDrawable(resources, bitmap))
}
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
Log.e("Bitmap failed", e)
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) {}
}
}
fun readFileFromStream(inputStream: InputStream): String {
val sb = StringBuilder()
var currentLine: String?
try {
BufferedReader(InputStreamReader(inputStream)).use { reader ->
while (reader.readLine().also { currentLine = it } != null) {
sb.append(currentLine).append("\n")
}
}
} catch (e: IOException) {
throw IllegalArgumentException("failed to read input")
}
return sb.toString()
}
fun stringToInputStream(str: String?): InputStream? {
return if (str == null) null else Buffer().writeUtf8(str).inputStream()
}
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?)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
package com.github.gotify.api
import android.app.Activity
import com.github.gotify.log.Log
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(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.body() ?: throw ApiException("null response", 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 = { data -> context.runOnUiThread { onSuccess.onSuccess(data) } },
onError = { exception -> context.runOnUiThread { onError.onError(exception) } }
)
}
fun <T> call(): retrofit2.Callback<T> {
return call(
onSuccess = {},
onError = {}
)
}
fun <T> call(onSuccess: SuccessCallback<T>, onError: 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 -> Log.e("Error while api call", exception) }
)
}
private fun <T> merge(left: Callback<T>, right: Callback<T>): Callback<T> {
return Callback(
onSuccess = { data ->
left.onSuccess.onSuccess(data)
right.onSuccess.onSuccess(data)
},
onError = { exception ->
left.onError.onError(exception)
right.onError.onError(exception)
}
)
}
}
}

View File

@@ -0,0 +1,96 @@
package com.github.gotify.api
import android.annotation.SuppressLint
import com.github.gotify.SSLSettings
import com.github.gotify.Utils
import com.github.gotify.log.Log
import okhttp3.OkHttpClient
import java.io.IOException
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.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
internal object CertUtils {
@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(cert: String): Certificate {
try {
val certificateFactory = CertificateFactory.getInstance("X509")
return certificateFactory.generateCertificate(Utils.stringToInputStream(cert))
} catch (e: Exception) {
throw IllegalArgumentException("certificate is invalid")
}
}
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package.
try {
if (!settings.validateSSL) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), arrayOf<TrustManager>(trustAll), SecureRandom())
builder.sslSocketFactory(context.socketFactory, trustAll)
builder.hostnameVerifier { _, _ -> true }
return
}
val cert = settings.cert
if (cert != null) {
val trustManagers = certToTrustManager(cert)
if (trustManagers.isNotEmpty()) {
val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), trustManagers, SecureRandom())
builder.sslSocketFactory(
context.socketFactory,
trustManagers[0] as X509TrustManager
)
}
}
} catch (e: Exception) {
// We shouldn't have issues since the cert is verified on login.
Log.e("Failed to apply SSL settings", e)
}
}
@Throws(GeneralSecurityException::class)
private fun certToTrustManager(cert: String): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert))
require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" }
val caKeyStore = newEmptyKeyStore()
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 newEmptyKeyStore(): KeyStore {
return try {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
keyStore
} catch (e: IOException) {
throw AssertionError(e)
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
package com.github.gotify.init
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
import com.github.gotify.NotificationSupport
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.api.ApiException
import com.github.gotify.api.Callback
import com.github.gotify.api.Callback.SuccessCallback
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.model.User
import com.github.gotify.client.model.VersionInfo
import com.github.gotify.log.Log
import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.login.LoginActivity
import com.github.gotify.messages.MessagesActivity
import com.github.gotify.service.WebSocketService
import com.github.gotify.settings.ThemeHelper
internal class InitializationActivity : AppCompatActivity() {
private lateinit var settings: Settings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.init(this)
val theme = PreferenceManager.getDefaultSharedPreferences(this)
.getString(getString(R.string.setting_key_theme), getString(R.string.theme_default))!!
ThemeHelper.setTheme(this, theme)
setContentView(R.layout.splash)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createChannels(
this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
)
}
UncaughtExceptionHandler.registerCurrentThread()
settings = Settings(this)
Log.i("Entering ${javaClass.simpleName}")
if (settings.tokenExists()) {
tryAuthenticate()
} else {
showLogin()
}
}
private fun showLogin() {
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
private fun tryAuthenticate() {
ClientFactory.userApiWithToken(settings)
.currentUser()
.enqueue(
Callback.callInUI(
this,
onSuccess = { user -> authenticated(user) },
onError = { exception -> failed(exception) }
)
)
}
private fun failed(exception: ApiException) {
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) {
AlertDialog.Builder(this)
.setTitle(R.string.oops)
.setMessage(message)
.setPositiveButton(R.string.retry) { _, _ -> tryAuthenticate() }
.setNegativeButton(R.string.logout) { _, _ -> showLogin() }
.show()
}
private fun authenticated(user: User) {
Log.i("Authenticated as ${user.name}")
settings.setUser(user.name, user.isAdmin)
requestVersion {
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 = { version: VersionInfo ->
Log.i("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.url, settings.sslSettings())
.version
.enqueue(Callback.callInUI(this, callback, errorCallback))
}
}

View File

@@ -0,0 +1,16 @@
package com.github.gotify.log
import android.content.Context
import com.hypertrack.hyperlog.LogFormat
internal class Format(context: Context) : LogFormat(context) {
override fun getFormattedLogMessage(
logLevelName: String,
tag: String,
message: String,
timeStamp: String,
senderName: String,
osVersion: String,
deviceUuid: String
) = "$timeStamp $logLevelName: $message"
}

View File

@@ -0,0 +1,47 @@
package com.github.gotify.log
import android.content.Context
import android.util.Log
import com.hypertrack.hyperlog.HyperLog
internal object Log {
private const val TAG = "gotify"
fun init(content: Context) {
HyperLog.initialize(content, Format(content))
HyperLog.setLogLevel(Log.INFO) // TODO configurable
}
fun get(): String {
val logs = HyperLog.getDeviceLogsAsStringList(false)
return logs.takeLast(200).reversed().joinToString("\n")
}
fun e(message: String) {
HyperLog.e(TAG, message)
}
fun e(message: String, e: Throwable) {
HyperLog.e(TAG, "$message\n${Log.getStackTraceString(e)}")
}
fun i(message: String) {
HyperLog.i(TAG, message)
}
fun i(message: String, e: Throwable) {
HyperLog.i(TAG, "$message\n${Log.getStackTraceString(e)}")
}
fun w(message: String) {
HyperLog.w(TAG, message)
}
fun w(message: String, e: Throwable) {
HyperLog.w(TAG, "$message\n${Log.getStackTraceString(e)}")
}
fun clear() {
HyperLog.deleteLogs()
}
}

View File

@@ -0,0 +1,82 @@
package com.github.gotify.log
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
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.gotify.R
import com.github.gotify.Utils
import com.github.gotify.Utils.launchCoroutine
import com.github.gotify.databinding.ActivityLogsBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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)
Log.i("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 = Log.get()
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 -> {
Log.clear()
binding.logContent.text = null
true
}
R.id.action_copy_logs -> {
val content = binding.logContent
val clipboardManager =
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("GotifyLog", content.text.toString())
clipboardManager.setPrimaryClip(clipData)
Utils.showSnackBar(this, getString(R.string.logs_copied))
true
}
else -> false
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.gotify.log
import com.github.gotify.log.Log.e
internal object UncaughtExceptionHandler {
fun registerCurrentThread() {
Thread.setDefaultUncaughtExceptionHandler { _, e: Throwable? ->
e("uncaught exception", e!!)
}
}
}

View File

@@ -0,0 +1,67 @@
package com.github.gotify.login
import android.app.AlertDialog
import android.content.Context
import android.view.LayoutInflater
import android.widget.CompoundButton
import com.github.gotify.R
import com.github.gotify.databinding.AdvancedSettingsDialogBinding
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
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 show(disableSSL: Boolean, selectedCertificate: String?): AdvancedDialog {
binding = AdvancedSettingsDialogBinding.inflate(layoutInflater)
binding.disableSSL.isChecked = disableSSL
binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener)
if (selectedCertificate == null) {
showSelectCACertificate()
} else {
showRemoveCACertificate(selectedCertificate)
}
AlertDialog.Builder(context)
.setView(binding.root)
.setTitle(R.string.advanced_settings)
.setPositiveButton(context.getString(R.string.done), null)
.show()
return this
}
private fun showSelectCACertificate() {
binding.toggleCaCert.setText(R.string.select_ca_certificate)
binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() }
binding.selecetedCaCert.setText(R.string.no_certificate_selected)
}
fun showRemoveCACertificate(certificate: String) {
binding.toggleCaCert.setText(R.string.remove_ca_certificate)
binding.toggleCaCert.setOnClickListener {
showSelectCACertificate()
onClickRemoveCaCertificate.run()
}
binding.selecetedCaCert.text = certificate
}
}

View File

@@ -0,0 +1,299 @@
package com.github.gotify.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 android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import com.github.gotify.R
import com.github.gotify.SSLSettings
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.ApiException
import com.github.gotify.api.Callback
import com.github.gotify.api.Callback.SuccessCallback
import com.github.gotify.api.CertUtils
import com.github.gotify.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.VersionInfo
import com.github.gotify.databinding.ActivityLoginBinding
import com.github.gotify.init.InitializationActivity
import com.github.gotify.log.Log
import com.github.gotify.log.LogsActivity
import com.github.gotify.log.UncaughtExceptionHandler
import okhttp3.HttpUrl
import java.security.cert.X509Certificate
internal class LoginActivity : AppCompatActivity() {
companion object {
// return value from startActivityForResult when choosing a certificate
private const val FILE_SELECT_CODE = 1
}
private lateinit var binding: ActivityLoginBinding
private lateinit var settings: Settings
private var disableSslValidation = false
private var caCertContents: String? = null
private lateinit var advancedDialog: AdvancedDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UncaughtExceptionHandler.registerCurrentThread()
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
Log.i("Entering ${javaClass.simpleName}")
settings = Settings(this)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.gotifyUrl.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.gotifyUrl.text.toString().trim().trimEnd('/')
val parsedUrl = HttpUrl.parse(url)
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(url, tempSslSettings())
.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() {
AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog))
.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() {
val selectedCertName = if (caCertContents != null) {
getNameOfCertContent(caCertContents!!)
} else {
null
}
advancedDialog = AdvancedDialog(this, layoutInflater)
.onDisableSSLChanged { _, disable ->
invalidateUrl()
disableSslValidation = disable
}
.onClickSelectCaCertificate {
invalidateUrl()
doSelectCACertificate()
}
.onClickRemoveCaCertificate {
invalidateUrl()
caCertContents = null
}
.show(disableSslValidation, selectedCertName)
}
private fun doSelectCACertificate() {
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 {
startActivityForResult(
Intent.createChooser(intent, getString(R.string.select_ca_file)),
FILE_SELECT_CODE
)
} catch (e: ActivityNotFoundException) {
// case for user not having a file browser installed
Utils.showSnackBar(this, getString(R.string.please_install_file_browser))
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
try {
if (requestCode == FILE_SELECT_CODE) {
require(resultCode == RESULT_OK) { "result was $resultCode" }
requireNotNull(data) { "file path was null" }
val uri = data.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val content = Utils.readFileFromStream(fileStream)
val name = getNameOfCertContent(content)
// temporarily set the contents (don't store to settings until they decide to login)
caCertContents = content
advancedDialog.showRemoveCACertificate(name)
}
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message))
}
}
private fun getNameOfCertContent(content: String): String {
val ca = CertUtils.parseCertificate(content)
return (ca as X509Certificate).subjectDN.name
}
private fun onValidUrl(url: String): SuccessCallback<VersionInfo> {
return SuccessCallback { 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.username.text.toString()
val password = binding.password.text.toString()
binding.login.visibility = View.GONE
binding.loginProgress.visibility = View.VISIBLE
val client = ClientFactory.basicAuth(settings.url, 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 clientName = EditText(this)
clientName.setText(Build.MODEL)
AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog))
.setTitle(R.string.create_client_title)
.setMessage(R.string.create_client_message)
.setView(clientName)
.setPositiveButton(R.string.create, doCreateClient(client, clientName))
.setNegativeButton(R.string.cancel) { _, _ -> onCancelClientDialog() }
.show()
}
private fun doCreateClient(
client: ApiClient,
nameProvider: EditText
): DialogInterface.OnClickListener {
return DialogInterface.OnClickListener { _, _ ->
val newClient = Client().name(nameProvider.text.toString())
client.createService(ClientApi::class.java)
.createClient(newClient)
.enqueue(
Callback.callInUI(
this,
onSuccess = { client -> onCreatedClient(client) },
onError = { onFailedToCreateClient() }
)
)
}
}
private fun onCreatedClient(client: Client) {
settings.token = client.token
settings.validateSSL = !disableSslValidation
settings.cert = caCertContents
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, caCertContents)
}
}

View File

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

View File

@@ -0,0 +1,181 @@
package com.github.gotify.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.RecyclerView
import androidx.viewbinding.ViewBinding
import com.github.gotify.MarkwonFactory
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.client.model.Message
import com.github.gotify.databinding.MessageItemBinding
import com.github.gotify.databinding.MessageItemCompactBinding
import com.github.gotify.messages.provider.MessageWithImage
import com.squareup.picasso.Picasso
import io.noties.markwon.Markwon
import org.threeten.bp.OffsetDateTime
import java.text.DateFormat
import java.util.Date
internal class ListMessageAdapter(
private val context: Context,
private val settings: Settings,
private val picasso: Picasso,
var items: List<MessageWithImage>,
private val delete: Delete
) : RecyclerView.Adapter<ListMessageAdapter.ViewHolder>() {
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val markwon: Markwon = MarkwonFactory.createForMessage(context, picasso)
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 = items[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
picasso.load(Utils.resolveAbsoluteUrl("${settings.url}/", message.image))
.error(R.drawable.ic_alarm)
.placeholder(R.drawable.ic_placeholder)
.into(holder.image)
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(holder.adapterPosition, message.message, false)
}
}
override fun getItemCount() = items.size
override fun getItemId(position: Int): Long {
val currentItem = items[position]
return currentItem.message.id
}
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
}
}
}
fun interface Delete {
fun delete(position: Int, message: Message, listAnimation: Boolean)
}
}

View File

@@ -0,0 +1,638 @@
package com.github.gotify.messages
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.net.Uri
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.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.gotify.BuildConfig
import com.github.gotify.MissedMessageUtil
import com.github.gotify.R
import com.github.gotify.Utils
import com.github.gotify.Utils.launchCoroutine
import com.github.gotify.api.Api
import com.github.gotify.api.ApiException
import com.github.gotify.api.Callback
import com.github.gotify.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.gotify.databinding.ActivityMessagesBinding
import com.github.gotify.init.InitializationActivity
import com.github.gotify.log.Log
import com.github.gotify.log.LogsActivity
import com.github.gotify.login.LoginActivity
import com.github.gotify.messages.provider.MessageState
import com.github.gotify.messages.provider.MessageWithImage
import com.github.gotify.service.WebSocketService
import com.github.gotify.settings.SettingsActivity
import com.github.gotify.sharing.ShareActivity
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 java.io.IOException
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 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]
Log.i("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,
viewModel.picassoHandler.get(),
emptyList()
) { position, message, listAnimation ->
scheduleDeletion(
position,
message,
listAnimation
)
}
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 onDrawerClosed(drawerView: View) {
updateAppOnDrawerClose?.let { selectApp ->
updateAppOnDrawerClose = null
viewModel.appId = selectApp
launchCoroutine {
updateMessagesForApplication(true, selectApp)
}
invalidateOptionsMenu()
}
}
}
)
swipeRefreshLayout.isEnabled = false
messagesView
.viewTreeObserver
.addOnScrollChangedListener {
val topChild = messagesView.getChildAt(0)
if (topChild != null) {
swipeRefreshLayout.isEnabled = topChild.top == 0
} else {
swipeRefreshLayout.isEnabled = true
}
}
launchCoroutine {
updateMessagesForApplication(true, viewModel.appId)
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
binding.learnGotify.setOnClickListener { openDocumentation() }
}
private fun refreshAll() {
try {
viewModel.picassoHandler.evict()
} catch (e: IOException) {
Log.e("Problem evicting Picasso cache", e)
}
startActivity(Intent(this, InitializationActivity::class.java))
finish()
}
private fun onRefresh() {
viewModel.messages.clear()
launchCoroutine {
loadMore(viewModel.appId)
}
}
private fun openDocumentation() {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gotify.net/docs/pushmsg"))
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(resources) { icon -> item.icon = icon }
viewModel.targetReferences.add(t)
viewModel.picassoHandler
.get()
.load(Utils.resolveAbsoluteUrl(viewModel.settings.url + "/", app.image))
.error(R.drawable.ic_alarm)
.placeholder(R.drawable.ic_placeholder)
.resize(100, 100)
.into(t)
}
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 = getString(R.string.connection, settings.user?.name, 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() }
}
override fun onBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
} else {
super.onBackPressed()
}
}
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) {
AlertDialog.Builder(ContextThemeWrapper(this, R.style.AppTheme_Dialog))
.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)
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
}
}
}
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(
position: Int,
message: Message,
listAnimation: Boolean
) {
val adapter = binding.messagesView.adapter as ListMessageAdapter
val messages = viewModel.messages
messages.deleteLocal(message)
adapter.items = messages[viewModel.appId]
if (listAnimation) {
adapter.notifyItemRemoved(position)
} else {
adapter.notifyDataSetChanged()
}
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.items = messages[appId]
val insertPosition = if (appId == MessageState.ALL_MESSAGES) {
deletion.allPosition
} else {
deletion.appPosition
}
adapter.notifyItemInserted(insertPosition)
}
}
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 = ColorDrawable(backgroundColorId)
}
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.items[position]
scheduleDeletion(position, message.message, true)
}
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) {
launchCoroutine {
deleteMessages(viewModel.appId)
}
}
if (item.itemId == R.id.action_delete_app) {
val alert = android.app.AlertDialog.Builder(this)
alert.setTitle(R.string.delete_app)
alert.setMessage(R.string.ack)
alert.setPositiveButton(R.string.yes) { _, _ -> deleteApp(viewModel.appId) }
alert.setNegativeButton(R.string.no, null)
alert.show()
}
return super.onContextItemSelected(item)
}
private fun deleteApp(appId: Long) {
val settings = viewModel.settings
val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token)
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) {
val messagesWithImages = viewModel.messages.loadMore(appId)
withContext(Dispatchers.Main) {
updateMessagesAndStopLoading(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.url, settings.sslSettings(), settings.token)
.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) {
Log.i("Delete client with id " + currentClient.id)
Api.execute(api.deleteClient(currentClient.id))
} else {
Log.e("Could not delete client, client does not exist.")
}
} catch (e: ApiException) {
Log.e("Could not delete client", e)
}
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.items = messageWithImages
adapter.notifyDataSetChanged()
}
companion object {
private const val APPLICATION_ORDER = 1
}
}

View File

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

View File

@@ -0,0 +1,18 @@
package com.github.gotify.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) {
return modelClass.cast(MessagesModel(modelParameterActivity)) as T
}
throw IllegalArgumentException(
"modelClass parameter must be of type ${MessagesModel::class.java.name}"
)
}
}

View File

@@ -0,0 +1,48 @@
package com.github.gotify.messages.provider
import android.app.Activity
import com.github.gotify.Utils
import com.github.gotify.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 = { apps -> onReceiveApps(apps) },
onError = { onFailedApps() }
)
)
}
private fun onReceiveApps(apps: List<Application>) {
state = apps
if (onUpdate != null) onUpdate!!.run()
}
private fun onFailedApps() {
Utils.showSnackBar(activity, "Could not request applications, see logs.")
if (onUpdateFailed != null) onUpdateFailed!!.run()
}
fun get() = state
fun onUpdate(runnable: Runnable?) {
onUpdate = runnable
}
fun onUpdateFailed(runnable: Runnable?) {
onUpdateFailed = runnable
}
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package com.github.gotify.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)
val result = mutableListOf<MessageWithImage>()
messages.forEach {
val messageWithImage = MessageWithImage()
messageWithImage.message = it
messageWithImage.image = appIdToImage[it.appid]!!
result.add(messageWithImage)
}
return result
}
fun appIdToImage(applications: List<Application>): Map<Long, String> {
val map = mutableMapOf<Long, String>()
applications.forEach {
map[it.id] = it.image
}
return map
}
}

View File

@@ -0,0 +1,49 @@
package com.github.gotify.messages.provider
import com.github.gotify.api.Api
import com.github.gotify.api.ApiException
import com.github.gotify.api.Callback
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import com.github.gotify.client.model.PagedMessages
import com.github.gotify.log.Log
internal class MessageRequester(private val messageApi: MessageApi) {
fun loadMore(state: MessageState): PagedMessages? {
return try {
Log.i("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) {
Log.e("failed requesting messages", apiException)
null
}
}
fun asyncRemoveMessage(message: Message) {
Log.i("Removing message with id ${message.id}")
messageApi.deleteMessage(message.id).enqueue(Callback.call())
}
fun deleteAll(appId: Long): Boolean {
return try {
Log.i("Deleting all messages for $appId")
if (MessageState.ALL_MESSAGES == appId) {
Api.execute(messageApi.deleteMessages())
} else {
Api.execute(messageApi.deleteAppMessages(appId))
}
true
} catch (e: ApiException) {
Log.e("Could not delete messages", e)
false
}
}
companion object {
private const val LIMIT = 100
}
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.github.gotify.messages.provider
import com.github.gotify.client.model.Message
internal class MessageWithImage {
lateinit var message: Message
lateinit var image: String
}

View File

@@ -0,0 +1,39 @@
package com.github.gotify.picasso
import android.graphics.BitmapFactory
import android.util.Base64
import com.github.gotify.log.Log
import com.squareup.picasso.Picasso
import com.squareup.picasso.Request
import com.squareup.picasso.RequestHandler
/**
* Adapted from https://github.com/square/picasso/issues/1395#issuecomment-220929377 By
* https://github.com/SmartDengg
*/
internal class PicassoDataRequestHandler : RequestHandler() {
companion object {
private const val DATA_SCHEME = "data"
}
override fun canHandleRequest(data: Request): Boolean {
val scheme = data.uri.scheme
return DATA_SCHEME.equals(scheme, ignoreCase = true)
}
override fun load(request: Request, networkPolicy: Int): Result {
val uri = request.uri.toString()
val imageDataBytes = uri.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")
Log.e("Could not load image", malformed)
throw malformed
}
return Result(bitmap, Picasso.LoadedFrom.NETWORK)
}
}

View File

@@ -0,0 +1,86 @@
package com.github.gotify.picasso
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.Callback
import com.github.gotify.api.CertUtils
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.log.Log
import com.github.gotify.messages.provider.MessageImageCombiner
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
internal class PicassoHandler(private val context: Context, private val settings: Settings) {
companion object {
private const val PICASSO_CACHE_SIZE = 50 * 1024 * 1024 // 50 MB
private const val PICASSO_CACHE_SUBFOLDER = "picasso-cache"
}
private val picassoCache = Cache(
File(context.cacheDir, PICASSO_CACHE_SUBFOLDER),
PICASSO_CACHE_SIZE.toLong()
)
private val picasso = makePicasso()
private val appIdToAppImage = ConcurrentHashMap<Long, String>()
private fun makePicasso(): Picasso {
val builder = OkHttpClient.Builder()
builder.cache(picassoCache)
CertUtils.applySslSettings(builder, settings.sslSettings())
val downloader = OkHttp3Downloader(builder.build())
return Picasso.Builder(context)
.addRequestHandler(PicassoDataRequestHandler())
.downloader(downloader)
.build()
}
@Throws(IOException::class)
fun getImageFromUrl(url: String?): Bitmap = picasso.load(url).get()
fun getIcon(appId: Long): Bitmap {
if (appId == -1L) {
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
try {
return getImageFromUrl(
Utils.resolveAbsoluteUrl("${settings.url}/", appIdToAppImage[appId])
)
} catch (e: IOException) {
Log.e("Could not load image for notification", e)
}
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
fun updateAppIds() {
ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token)
.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.call(
onSuccess = { apps ->
appIdToAppImage.clear()
appIdToAppImage.putAll(MessageImageCombiner.appIdToImage(apps))
},
onError = { appIdToAppImage.clear() }
)
)
}
fun get() = picasso
@Throws(IOException::class)
fun evict() {
picassoCache.evictAll()
}
}

View File

@@ -0,0 +1,237 @@
package com.github.gotify.service
import android.app.AlarmManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.github.gotify.SSLSettings
import com.github.gotify.Utils
import com.github.gotify.api.Callback.SuccessCallback
import com.github.gotify.api.CertUtils
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.Calendar
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
internal class WebSocketConnection(
private val baseUrl: String,
settings: SSLSettings,
private val token: String?,
private val connectivityManager: ConnectivityManager,
private val alarmManager: AlarmManager
) {
companion object {
private val ID = AtomicLong(0)
}
private val client: OkHttpClient
private val reconnectHandler = Handler(Looper.getMainLooper())
private val reconnectCallback = Runnable { start() }
private var errorCount = 0
private var webSocket: WebSocket? = null
private lateinit var onMessage: SuccessCallback<Message>
private lateinit var onClose: Runnable
private lateinit var onOpen: Runnable
private lateinit var onBadRequest: BadRequestRunnable
private lateinit var onNetworkFailure: 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: SuccessCallback<Message>): WebSocketConnection {
this.onMessage = 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 onBadRequest(onBadRequest: BadRequestRunnable): WebSocketConnection {
this.onBadRequest = onBadRequest
return this
}
@Synchronized
fun onNetworkFailure(onNetworkFailure: OnNetworkFailureRunnable): WebSocketConnection {
this.onNetworkFailure = onNetworkFailure
return this
}
@Synchronized
fun onReconnected(onReconnected: Runnable): WebSocketConnection {
this.onReconnected = onReconnected
return this
}
private fun request(): Request {
val url = HttpUrl.parse(baseUrl)!!
.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()
Log.i("WebSocket($nextId): starting...")
webSocket = client.newWebSocket(request(), Listener(nextId))
return this
}
@Synchronized
fun close() {
if (webSocket != null) {
Log.i("WebSocket(${ID.get()}): closing existing connection.")
state = State.Disconnected
webSocket!!.close(1000, "")
webSocket = null
}
}
@Synchronized
fun scheduleReconnect(seconds: Long) {
if (state == State.Connecting || state == State.Connected) {
return
}
state = State.Scheduled
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Log.i("WebSocket: scheduling a restart in $seconds second(s) (via alarm manager)")
val future = Calendar.getInstance()
future.add(Calendar.SECOND, seconds.toInt())
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
future.timeInMillis,
"reconnect-tag",
{ start() },
null
)
} else {
Log.i("WebSocket: scheduling a restart in $seconds second(s)")
reconnectHandler.removeCallbacks(reconnectCallback)
reconnectHandler.postDelayed(reconnectCallback, TimeUnit.SECONDS.toMillis(seconds))
}
}
private inner class Listener(private val id: Long) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
syncExec {
state = State.Connected
Log.i("WebSocket($id): opened")
onOpen.run()
if (errorCount > 0) {
onReconnected.run()
errorCount = 0
}
}
super.onOpen(webSocket, response)
}
override fun onMessage(webSocket: WebSocket, text: String) {
syncExec {
Log.i("WebSocket($id): received message $text")
val message = Utils.JSON.fromJson(text, Message::class.java)
onMessage.onSuccess(message)
}
super.onMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
syncExec {
if (state == State.Connected) {
Log.w("WebSocket($id): closed")
onClose.run()
}
state = State.Disconnected
}
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 = if (response != null) response.message() else ""
Log.e("WebSocket($id): failure $code Message: $message", t)
syncExec {
state = State.Disconnected
if (response != null && response.code() >= 400 && response.code() <= 499) {
onBadRequest.execute(message)
close()
return@syncExec
}
errorCount++
val network = connectivityManager.activeNetworkInfo
if (network == null || !network.isConnected) {
Log.i("WebSocket($id): Network not connected")
}
val minutes = (errorCount * 2 - 1).coerceAtMost(20)
onNetworkFailure.execute(minutes)
scheduleReconnect(TimeUnit.MINUTES.toSeconds(minutes.toLong()))
}
super.onFailure(webSocket, t, response)
}
private fun syncExec(runnable: Runnable) {
synchronized(this) {
if (ID.get() == id) {
runnable.run()
}
}
}
}
internal fun interface BadRequestRunnable {
fun execute(message: String)
}
internal fun interface OnNetworkFailureRunnable {
fun execute(minutes: Int)
}
internal enum class State {
Scheduled,
Connecting,
Connected,
Disconnected
}
}

View File

@@ -0,0 +1,398 @@
package com.github.gotify.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.IntentFilter
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.github.gotify.MarkwonFactory
import com.github.gotify.MissedMessageUtil
import com.github.gotify.NotificationSupport
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.Callback
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.messages.Extras
import com.github.gotify.messages.MessagesActivity
import com.github.gotify.picasso.PicassoHandler
import io.noties.markwon.Markwon
import java.util.concurrent.atomic.AtomicLong
internal class WebSocketService : Service() {
companion object {
val NEW_MESSAGE_BROADCAST = "${WebSocketService::class.java.name}.NEW_MESSAGE"
private const val NOT_LOADED = -2L
}
private lateinit var settings: Settings
private var connection: WebSocketConnection? = null
private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil
private lateinit var picassoHandler: PicassoHandler
private lateinit var markwon: Markwon
override fun onCreate() {
super.onCreate()
settings = Settings(this)
val client = ClientFactory.clientToken(
settings.url,
settings.sslSettings(),
settings.token
)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Log.i("Create ${javaClass.simpleName}")
picassoHandler = PicassoHandler(this, settings)
markwon = MarkwonFactory.createForNotification(this, picassoHandler.get())
}
override fun onDestroy() {
super.onDestroy()
if (connection != null) {
connection!!.close()
}
Log.w("Destroy ${javaClass.simpleName}")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.init(this)
if (connection != null) {
connection!!.close()
}
Log.i("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,
cm,
alarmManager
)
.onOpen { onOpen() }
.onClose { onClose() }
.onBadRequest { message -> onBadRequest(message) }
.onNetworkFailure { minutes -> onNetworkFailure(minutes) }
.onMessage { message -> onMessage(message) }
.onReconnected { notifyMissedNotifications() }
.start()
val intentFilter = IntentFilter()
intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
picassoHandler.updateAppIds()
}
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 {
Log.i(
"WebSocket closed but the user still authenticated, " +
"trying to reconnect"
)
doReconnect()
}
}
)
)
}
private fun doReconnect() {
if (connection == null) {
return
}
connection!!.scheduleReconnect(15)
}
private fun onBadRequest(message: String) {
showForegroundNotification(getString(R.string.websocket_could_not_connect), message)
}
private fun onNetworkFailure(minutes: Int) {
val status = getString(R.string.websocket_not_connected)
val intervalUnit = resources
.getQuantityString(R.plurals.websocket_retry_interval, minutes, minutes)
showForegroundNotification(
status,
"${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)
}
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)
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)
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) {
intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(intentUrl)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
val url = Extras.getNestedValue(
String::class.java,
extras,
"client::notification",
"click",
"url"
)
if (url != null) {
intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(url)
} else {
intent = Intent(this, MessagesActivity::class.java)
}
val contentIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val b = NotificationCompat.Builder(
this,
NotificationSupport.convertPriorityToChannel(priority)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
showNotificationGroup(priority)
}
b.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(picassoHandler.getIcon(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
lateinit var newMessage: String
if (Extras.useMarkdown(extras)) {
formattedMessage = markwon.toMarkdown(message)
newMessage = formattedMessage.toString()
}
b.setContentText(newMessage)
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(picassoHandler.getImageFromUrl(notificationImageUrl))
)
} catch (e: Exception) {
Log.e("Error loading bigImageUrl", e)
}
}
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(Utils.longToInt(id), b.build())
}
@RequiresApi(Build.VERSION_CODES.N)
fun showNotificationGroup(priority: Long) {
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,
NotificationSupport.convertPriorityToChannel(priority)
)
builder.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setTicker(getString(R.string.app_name))
.setGroup(NotificationSupport.Group.MESSAGES)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setContentTitle(getString(R.string.grouped_notification_text))
.setGroupSummary(true)
.setContentText(getString(R.string.grouped_notification_text))
.setColor(ContextCompat.getColor(applicationContext, R.color.colorPrimary))
.setContentIntent(contentIntent)
val notificationManager = this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(-5, builder.build())
}
}

View File

@@ -0,0 +1,94 @@
package com.github.gotify.settings
import android.app.AlertDialog
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.github.gotify.R
import com.github.gotify.databinding.SettingsActivityBinding
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 (getString(R.string.setting_key_theme) == key) {
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)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val messageLayout: ListPreference? =
findPreference(getString(R.string.setting_key_message_layout))
messageLayout?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
AlertDialog.Builder(context)
.setTitle(R.string.setting_message_layout_dialog_title)
.setMessage(R.string.setting_message_layout_dialog_message)
.setPositiveButton(
getString(R.string.setting_message_layout_dialog_button1)
) { _, _ ->
restartApp()
}
.setNegativeButton(
getString(R.string.setting_message_layout_dialog_button2),
null
)
.show()
true
}
}
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)
}
}
}

View File

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

View File

@@ -0,0 +1,163 @@
package com.github.gotify.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.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils.launchCoroutine
import com.github.gotify.api.Api
import com.github.gotify.api.ApiException
import com.github.gotify.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.gotify.databinding.ActivityShareBinding
import com.github.gotify.log.Log
import com.github.gotify.messages.provider.ApplicationHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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)
Log.i("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.url,
settings.sslSettings(),
settings.token
)
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.url,
settings.sslSettings(),
appsHolder.get()[appIndex].token
)
return try {
val messageApi = pushClient.createService(MessageApi::class.java)
Api.execute(messageApi.createMessage(message))
true
} catch (apiException: ApiException) {
Log.e("Failed sending message", apiException)
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
}
}