Service to permit other app to register and forward the notifications to theses apps

This commit is contained in:
gougeon-s
2020-05-31 18:24:45 +02:00
parent b35eb06949
commit f650de44cf
8 changed files with 532 additions and 9 deletions

View File

@@ -2,6 +2,8 @@ plugins {
id "com.diffplug.gradle.spotless" version "3.26.1"
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 29
@@ -54,6 +56,8 @@ dependencies {
implementation 'io.noties.markwon:image-picasso:4.3.1'
implementation 'io.noties.markwon:image:4.3.1'
implementation 'io.noties.markwon:ext-tables:4.3.1'
implementation "androidx.core:core-ktx:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
configurations {
@@ -70,3 +74,6 @@ spotless {
importOrder('', 'static *')
}
}
repositories {
mavenCentral()
}

View File

@@ -47,6 +47,8 @@
android:label="@string/title_activity_settings" />
<service android:name=".service.WebSocketService" />
<service android:name=".service.GotifyPushNotification" />
<service android:name=".service.GotifyMessengerService" android:exported="true" />
<receiver android:name=".init.BootCompletedReceiver">
<intent-filter>

View File

@@ -0,0 +1,20 @@
package com.github.gotify.service
/**
* Command to the service to register a client, receiving callbacks
* from the service. The Message's replyTo field must be a Messenger of
* the client where callbacks should be sent.
*/
const val MSG_REGISTER_CLIENT = 1
const val MSG_UNREGISTER_CLIENT = 2
const val MSG_START = 3
const val MSG_GET_INFO = 4
const val MSG_NEW_URL = 5
const val MSG_NOTIFICATION = 6
const val MSG_IS_REGISTERED = 7
/**
* ERRORS
*/
const val ERROR_NOT_CONNECTED = 1
const val ERROR_NULL_OR_BLANK = 2
const val ERROR_ALREADY_REGISTERED = 3

View File

@@ -0,0 +1,189 @@
package com.github.gotify.service
import android.app.Service
import android.content.Intent
import android.os.*
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import com.github.gotify.Settings
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.log.Log
import java.io.IOException
/**
* THIS SERVICE IS USED BY OTHER APPS TO REGISTER
*/
// TODO : in the app, implement forceUnregisterApp
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class GotifyMessengerService : Service() {
/** Keeps track of all current registered clients. */
private val db = MessagingDatabase(this)
private lateinit var settings: Settings //= Settings(this)
/**
* Handler of incoming messages from clients.
*/
internal inner class gHandler : Handler() {
override fun handleMessage(msg: Message) {
when (msg.what) {
MSG_START -> simpleAnswer(msg, MSG_START,0)
MSG_REGISTER_CLIENT -> {
val uid = msg.sendingUid
val msgData = msg.data
val thread = object : Thread() {
override fun run() {
registerApp(msgData, uid)
}
}
thread.start()
thread.join()
simpleAnswer(msg, MSG_REGISTER_CLIENT,0)
}
MSG_UNREGISTER_CLIENT -> {
unregisterApp(msg.data,msg.sendingUid,true)
simpleAnswer(msg, MSG_UNREGISTER_CLIENT,0)
}
MSG_GET_INFO -> sendInfo(msg)
MSG_IS_REGISTERED -> {
var rep =0
if(db.strictIsRegistered(msg.data.getString("package").toString(),msg.sendingUid)){
rep = 1
}
simpleAnswer(msg, MSG_IS_REGISTERED,rep)
}
else -> super.handleMessage(msg)
}
}
private fun simpleAnswer(msg: Message, what: Int, arg1: Int) {
try {
msg.replyTo?.send(Message.obtain(null, what, arg1, 0))
} catch (e: RemoteException) {
}
}
private fun unregisterApp(msg: Bundle, clientUid: Int, deleteApp: Boolean) {
val clientPackageName = msg.getString("package").toString()
// we only trust unregistered demands from the uid who registered the app
if (db.strictIsRegistered(clientPackageName, clientUid)) {
Log.i("Unregistering $clientPackageName uid: $clientUid")
db.unregisterApp(clientPackageName, clientUid)
if(deleteApp){
// TODO : delete app on the server,
// it can be done manually for the moment
}
}
}
private fun registerApp(msg: Bundle,clientUid: Int) {
val clientPackageName = msg.getString("package").toString()
if (clientPackageName.isBlank()) {
Log.w("Trying to register an app without packageName")
return
}
Log.i("registering $clientPackageName uid: $clientUid")
// The app is registered with the same uid : we re-register it
// the client may need to create a new app in the server
if (db.strictIsRegistered(clientPackageName, clientUid)) {
Log.i("$clientPackageName already registered : unregistering to register again")
unregisterApp(msg,clientUid,false)
}
// The app is registered with a new uid.
// User should unregister this app manually
// to avoid an app to impersonate another one
if (db.isRegistered(clientPackageName)) {
Log.w("$clientPackageName already registered with a different uid")
return
}
//
val clientService = msg.getString("service").toString()
if (clientService.isBlank()) {
Log.w("Cannot find the service for $clientPackageName")
return
}
var app = getApp(clientPackageName)
if (app == null){
Log.i("$clientPackageName isn't registered to the server, creating a new one")
app = createApp(clientPackageName)
}
if(app == null){
Log.w("Cannot find the AppId neither create a new app to register")
return
}
Log.i("registering : $clientPackageName $clientUid $clientService ${app.id} ${app.token}")
db.registerApp(clientPackageName, clientUid, clientService, app.id, app.token)
}
private fun sendInfo(msg: Message){
val clientPackageName = msg.data?.getString("package").toString()
Log.i("$clientPackageName is asking for its token and url")
// we only trust unregistered demands from the uid who registered the app
if (db.strictIsRegistered(clientPackageName, msg.sendingUid)) {
val token = db.getToken(clientPackageName)
try {
val answer = Message.obtain(null, MSG_GET_INFO, 0, 0)
answer.data = bundleOf("url" to settings.url(),
"token" to token)
msg.replyTo?.send(answer)
} catch (e: RemoteException) {
}
}else{
Log.w("Client isn't registered or has a different uid")
}
}
}
/**
* Target we publish for clients to send messages to gHandler.
*/
private val gMessenger = Messenger(gHandler())
/**
* When binding to the service, we return an interface to our messenger
* for sending messages to the service.
*/
override fun onBind(intent: Intent): IBinder? {
settings = Settings(this)
return gMessenger.binder
}
override fun onDestroy() {
db.close()
super.onDestroy()
}
private fun getApp(appName: String): com.github.gotify.client.model.Application? {
val client = ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token())
var applications: List<com.github.gotify.client.model.Application>? = null
try{
applications = client.createService(ApplicationApi::class.java).apps.execute().body()
}catch(e: IOException){
e.printStackTrace()
}
if(applications.isNullOrEmpty()) {
Log.i("There isn't any app registered to the server")
return null
}
for (app in applications) {
if (app.name == appName) return app
}
return null
}
private fun createApp(appName: String): com.github.gotify.client.model.Application? {
val client = ClientFactory.clientToken(settings.url(), settings.sslSettings(), settings.token())
val app = com.github.gotify.client.model.Application()
app.name = appName
app.description = "automatically created"
try{
return client.createService(ApplicationApi::class.java).createApp(app).execute().body()
}catch(e: IOException){
e.printStackTrace()
}
return null
}
}

View File

@@ -0,0 +1,141 @@
package com.github.gotify.service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.*
import androidx.core.os.bundleOf
import com.github.gotify.log.Log
/**
* THIS CLASS IS USED TO PUSH NOTIFICATIONS TO OTHER APPS
* It is called from the thread in WebSocketService
*/
//TODO : delete the notification once delivered
//TODO: Continue to implement the URl change
// For the moment, re-registering the client is enough
/**
* Function to notify client
*/
fun notifyClient(context: Context,app: String, msg: com.github.gotify.client.model.Message){
val gotif = GotifyPushNotification(context,app,msg)
gotif.start()
}
open class GotifyPushNotification(private val context: Context,
private val clientPackage: String,
private val message: com.github.gotify.client.model.Message){
/** Messenger for communicating with service. */
private var gService: Messenger? = null
/** To known if it if bound to the service */
private var gIsBound = false
/** Handler of incoming messages from service. */
private val gHandlerThread = HandlerThread(clientPackage)
private var gHandler: Handler? = null
/** Target we publish for client apps to send messages to gHandler. */
private var gMessenger: Messenger? = null
fun start(){
gHandlerThread.start()
gHandler = Handler(gHandlerThread.looper)
gMessenger = Messenger(gHandler)
doBindService()
}
/**
* Class for interacting with the main interface of the service.
*/
private val gConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName,
service: IBinder) {
gService = Messenger(service)
gIsBound = true
Log.i("Remote service connected")
/** We're connected, now we send the message !**/
doNotifyClient()
}
override fun onServiceDisconnected(className: ComponentName) {
// This is called when the connection with the service has been
// unexpectedly disconnected -- that is, its process crashed.
gService = null
doUnbindService()
Log.i("Remote service disconnected")
}
}
private fun doBindService() {
val intent = Intent()
val db = MessagingDatabase(context)
val service = db.getServiceName(clientPackage)
if (service.isBlank()){
Log.w("No service found for $clientPackage")
doUnbindService()
return
}
intent.component = ComponentName(clientPackage , service)
context.bindService( intent, gConnection, Context.BIND_AUTO_CREATE )
// We don't do if(true){gIsBound = true} now because
// we consider it is bound only when the service is connected
}
private fun doUnbindService() {
Log.i("Unbinding")
if (gIsBound) {
// Detach our existing connection.
context.unbindService(gConnection)
gIsBound = false
}
gHandlerThread.quit()
gHandler = null
gMessenger = null
}
private fun doChangeURL(url: String){
if(!gIsBound){
Log.w("You need to bind fisrt")
doUnbindService()
return
}
try {
val msg = Message.obtain(null,
MSG_NEW_URL, 0, 0)
msg.replyTo = gMessenger
msg.data = bundleOf("url" to url)
gService!!.send(msg)
} catch (e: RemoteException) {
// There is nothing special we need to do if the service
// has crashed.
}
//done : unbinding
doUnbindService()
}
private fun doNotifyClient() {
if (!gIsBound) {
Log.w("You need to bind first")
doUnbindService()
return
}
try {
val msg = Message.obtain(
null,
MSG_NOTIFICATION, 0, 0
)
msg.replyTo = gMessenger
msg.data = bundleOf("message" to message.message,
"title" to message.title,
"priority" to message.priority.toInt())
gService!!.send(msg)
Log.i("Notification sent")
} catch (e: RemoteException) {
// There is nothing special we need to do if the service
// has crashed.
}
/** Once the notification is delivered, we can unbind **/
doUnbindService()
}
}

View File

@@ -0,0 +1,154 @@
package com.github.gotify.service
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
private val DB_NAME = "gotify_service"
private val DB_VERSION = 1
class MessagingDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION){
private val CREATE_TABLE_APPS = "CREATE TABLE apps (" +
"package_name TEXT," +
"uid INT," +
"service_name TEXT," +
"app_id INT," +
"token TEXT," +
"PRIMARY KEY (package_name));"
private val TABLE_APPS = "apps"
private val FIELD_PACKAGE_NAME = "package_name"
private val FIELD_UID = "uid"
private val FIELD_SERVICE_NAME = "service_name"
private val FIELD_APP_ID = "app_id"
private val FIELD_TOKEN = "token"
override fun onCreate(db: SQLiteDatabase){
db.execSQL(CREATE_TABLE_APPS);
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
throw IllegalStateException("Upgrades not supported")
}
fun registerApp(packageName: String, uid: Int, serviceName: String, appId :Int, token: String){
val db = writableDatabase
val values = ContentValues().apply {
put(FIELD_PACKAGE_NAME, packageName)
put(FIELD_UID, uid.toString())
put(FIELD_SERVICE_NAME,serviceName)
put(FIELD_APP_ID,appId.toString())
put(FIELD_TOKEN,token)
}
db.insert(TABLE_APPS,null,values)
}
fun unregisterApp(packageName: String, uid: Int){
val db = writableDatabase
val selection = "$FIELD_PACKAGE_NAME = ? AND $FIELD_UID = ?"
val selectionArgs = arrayOf(packageName,uid.toString())
db.delete(TABLE_APPS,selection,selectionArgs)
}
fun forceUnregisterApp(packageName: String){
val db = writableDatabase
val selection = "$FIELD_PACKAGE_NAME = ?"
val selectionArgs = arrayOf(packageName)
db.delete(TABLE_APPS,selection,selectionArgs)
}
fun isRegistered(packageName: String): Boolean {
val db = readableDatabase
val selection = "$FIELD_PACKAGE_NAME = ?"
val selectionArgs = arrayOf(packageName)
val cursor = db.query(
TABLE_APPS,
null,
selection,
selectionArgs,
null,
null,
null
)
return (cursor != null && cursor.count > 0)
}
fun strictIsRegistered(packageName: String, uid: Int): Boolean {
val db = readableDatabase
val selection = "$FIELD_PACKAGE_NAME = ? AND $FIELD_UID = ?"
val selectionArgs = arrayOf(packageName,uid.toString())
val cursor = db.query(
TABLE_APPS,
null,
selection,
selectionArgs,
null,
null,
null
)
return (cursor != null && cursor.count > 0)
}
fun getServiceName(packageName: String): String{
val db = readableDatabase
val projection = arrayOf(FIELD_SERVICE_NAME)
val selection = "$FIELD_PACKAGE_NAME = ?"
val selectionArgs = arrayOf(packageName)
val cursor = db.query(
TABLE_APPS,
projection,
selection,
selectionArgs,
null,
null,
null
)
var res = ""
if(cursor.moveToFirst()){
res = cursor.getString(cursor.getColumnIndex(FIELD_SERVICE_NAME))
}
return res
}
fun getAppFromId(appId: Int): String{
val db = readableDatabase
val projection = arrayOf(FIELD_PACKAGE_NAME)
val selection = "$FIELD_APP_ID = ?"
val selectionArgs = arrayOf(appId.toString())
val cursor = db.query(
TABLE_APPS,
projection,
selection,
selectionArgs,
null,
null,
null
)
var res = ""
if(cursor.moveToFirst()){
res = cursor.getString(cursor.getColumnIndex(FIELD_PACKAGE_NAME))
}
return res
}
fun getToken(packageName: String): String{
val db = readableDatabase
val projection = arrayOf(FIELD_TOKEN)
val selection = "$FIELD_PACKAGE_NAME = ?"
val selectionArgs = arrayOf(packageName)
val cursor = db.query(
TABLE_APPS,
projection,
selection,
selectionArgs,
null,
null,
null
)
var res = ""
if(cursor.moveToFirst()){
res = cursor.getString(cursor.getColumnIndex(FIELD_TOKEN))
}
return res
}
}

View File

@@ -172,12 +172,20 @@ public class WebSocketService extends Service {
lastReceivedMessage.set(message.getId());
}
broadcast(message);
showNotification(
message.getId(),
message.getTitle(),
message.getMessage(),
message.getPriority(),
message.getExtras());
MessagingDatabase db = new MessagingDatabase(this);
String registeredAppName = db.getAppFromId(message.getAppid());
db.close();
if(!registeredAppName.isEmpty() && registeredAppName != "") {
Log.i("Notification to " + registeredAppName);
GotifyPushNotificationKt.notifyClient(this, registeredAppName, message);
}else {
showNotification(
message.getId(),
message.getTitle(),
message.getMessage(),
message.getPriority(),
message.getExtras());
}
}
private void broadcast(Message message) {
@@ -300,4 +308,4 @@ public class WebSocketService extends Service {
(NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(-5, b.build());
}
}
}

View File

@@ -1,14 +1,16 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.72'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files