diff --git a/app/build.gradle b/app/build.gradle index e00b562..de7d26a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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() +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 314b2b1..0100d91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,8 @@ android:label="@string/title_activity_settings" /> + + diff --git a/app/src/main/java/com/github/gotify/service/Constants.kt b/app/src/main/java/com/github/gotify/service/Constants.kt new file mode 100644 index 0000000..9e4a983 --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/Constants.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/com/github/gotify/service/GotifyMessengerService.kt b/app/src/main/java/com/github/gotify/service/GotifyMessengerService.kt new file mode 100644 index 0000000..90cb7a9 --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/GotifyMessengerService.kt @@ -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? = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/gotify/service/GotifyPushNotification.kt b/app/src/main/java/com/github/gotify/service/GotifyPushNotification.kt new file mode 100644 index 0000000..5f7e25c --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/GotifyPushNotification.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt b/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt new file mode 100644 index 0000000..633d3e1 --- /dev/null +++ b/app/src/main/java/com/github/gotify/service/MessagingDatabase.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/gotify/service/WebSocketService.java b/app/src/main/java/com/github/gotify/service/WebSocketService.java index 480fc01..5dda07d 100644 --- a/app/src/main/java/com/github/gotify/service/WebSocketService.java +++ b/app/src/main/java/com/github/gotify/service/WebSocketService.java @@ -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()); } -} +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index b0cb412..a508aa4 100644 --- a/build.gradle +++ b/build.gradle @@ -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