From 993ffaf8f9d147de9b0abde13ed220988c5370f3 Mon Sep 17 00:00:00 2001 From: S1m Date: Mon, 22 Nov 2021 09:27:58 +0100 Subject: [PATCH] Init Working SSE --- .github/dependabot.yml | 7 + .github/workflows/main.yml | 38 +++ .gitignore | 15 ++ app/.gitignore | 1 + app/build.gradle | 68 ++++++ app/proguard-rules.pro | 21 ++ app/src/main/AndroidManifest.xml | 54 +++++ app/src/main/ic_launcher-playstore.png | Bin 0 -> 19896 bytes .../nextpush/account/AccountUtils.kt | 61 +++++ .../nextpush/activities/MainActivity.kt | 220 ++++++++++++++++++ .../nextpush/activities/SettingsActivity.kt | 48 ++++ .../distributor/nextpush/api/ApiResponse.kt | 7 + .../distributor/nextpush/api/ApiUtils.kt | 163 +++++++++++++ .../distributor/nextpush/api/ProviderApi.kt | 34 +++ .../distributor/nextpush/api/SSEResponse.kt | 7 + .../nextpush/distributor/DistributorUtils.kt | 66 ++++++ .../nextpush/distributor/MessagingDatabase.kt | 100 ++++++++ .../distributor/UnifiedPushConstants.kt | 22 ++ .../receivers/RegisterBroadcastReceiver.kt | 65 ++++++ .../nextpush/receivers/StartReceiver.kt | 23 ++ .../nextpush/services/SSEListener.kt | 55 +++++ .../nextpush/services/StartService.kt | 106 +++++++++ .../res/drawable/ic_launcher_foreground.xml | 20 ++ .../res/drawable/ic_launcher_notification.xml | 20 ++ app/src/main/res/layout/activity_main.xml | 31 +++ app/src/main/res/layout/activity_settings.xml | 110 +++++++++ app/src/main/res/layout/content_main.xml | 55 +++++ app/src/main/res/layout/content_start.xml | 32 +++ app/src/main/res/menu/menu_main.xml | 20 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2066 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4106 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1255 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2279 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2934 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 5815 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 4316 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 9294 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 6155 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 13294 bytes app/src/main/res/values-night/themes.xml | 16 ++ app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/dimens.xml | 3 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 27 +++ app/src/main/res/values/themes.xml | 25 ++ .../main/res/xml/network_security_config.xml | 10 + build.gradle | 27 +++ gradle.properties | 21 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 +++++++++++++++ gradlew.bat | 89 +++++++ settings.gradle | 2 + 55 files changed, 1904 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/account/AccountUtils.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/activities/MainActivity.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/activities/SettingsActivity.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiResponse.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiUtils.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/api/ProviderApi.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/api/SSEResponse.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/DistributorUtils.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/MessagingDatabase.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/UnifiedPushConstants.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/RegisterBroadcastReceiver.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/StartReceiver.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/services/SSEListener.kt create mode 100644 app/src/main/java/org/unifiedpush/distributor/nextpush/services/StartService.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_notification.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/content_main.xml create mode 100644 app/src/main/res/layout/content_start.xml create mode 100644 app/src/main/res/menu/menu_main.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..34477c7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: gradle + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..8217f3d --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,38 @@ + +on: [push, pull_request] + +name: Build + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v1 + with: + java-version: 11 + - run: ./gradlew build --stacktrace + - uses: actions/upload-artifact@v2 + with: + name: build + path: app/build/outputs/apk/debug/app-debug.apk + - if: startsWith(github.ref, 'refs/tags/') + run: | + cd app/build/outputs/apk/release + echo $RELEASE_KEY | base64 -d > release-key.jks + jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore release-key.jks -storepass $STOREPASS -keypass $KEYPASS app-release-unsigned.apk nextpush + jarsigner -verify app-release-unsigned.apk + sudo apt-get install zipalign -y + zipalign -v 4 app-release-unsigned.apk nextpush.apk + env: + RELEASE_KEY: ${{ secrets.RELEASE_KEY }} + KEYPASS: ${{ secrets.KEYPASS }} + STOREPASS: ${{ secrets.STOREPASS }} + - if: startsWith(github.ref, 'refs/tags/') + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: app/build/outputs/apk/release/nextpush.apk + tag: ${{ github.ref }} + overwrite: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..d77468e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.3" + configurations.all { + resolutionStrategy { + force 'androidx.core:core-ktx:1.6.0' + force 'androidx.core:core:1.6.0' + } + } + + defaultConfig { + applicationId "org.unifiedpush.distributor.nextpush" + minSdkVersion 24 + targetSdkVersion 30 + versionCode 1 + versionName "0.0.1" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + resValue "string", "app_name", "NextPush" + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + resValue "string", "app_name", "NextPush-dbg" + applicationIdSuffix ".debug" + debuggable true + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + + packagingOptions { + exclude("META-INF/*") + } + +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.6.0' + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.1' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.1")) + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") + implementation("com.squareup.okhttp3:okhttp-sse") + implementation 'com.github.nextcloud:Android-SingleSignOn:0.6.0' + implementation "com.squareup.retrofit2:retrofit:2.9.0" + implementation "com.squareup.retrofit2:converter-gson:2.9.0" + implementation "com.squareup.retrofit2:adapter-rxjava2:2.9.0" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..318d8d0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..307bed51de4a0e48049e6ae37bc0e858f4831ce1 GIT binary patch literal 19896 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.id.button_connection).setOnClickListener { + connect(this) + } + showStart() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + try { + AccountImporter.onActivityResult( + requestCode, + resultCode, + data, + this, + object : IAccountAccessGranted { + var callback: ApiConnectedListener = object : ApiConnectedListener { + override fun onConnected() {} + + override fun onError(ex: Exception) { + Log.e(TAG, "Cannot get account access", ex) + } + } + + override fun accountAccessGranted(account: SingleSignOnAccount) { + val context = applicationContext + + // As this library supports multiple accounts we created some helper methods if you only want to use one. + // The following line stores the selected account as the "default" account which can be queried by using + // the SingleAccountHelper.getCurrentSingleSignOnAccount(context) method + SingleAccountHelper.setCurrentAccount(context, account.name) + + // Get the "default" account + try { + ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(context) + } catch (e: NextcloudFilesAppAccountNotFoundException) { + UiExceptionManager.showDialogForException(context, e) + } catch (e: NoCurrentAccountSelectedException) { + UiExceptionManager.showDialogForException(context, e) + } + showMain() + } + }) + } catch (e: AccountImportCancelledException) {} + } + + private fun showMain() { + findViewById(R.id.sub_start).isVisible = false + findViewById(R.id.sub_main).isVisible = true + findViewById(R.id.main_account_desc).text = + format(getString(R.string.main_account_desc), ssoAccount.name) + showLogout = true + invalidateOptionsMenu() + startListener() + } + + private fun showStart() { + findViewById(R.id.sub_start).isVisible = true + findViewById(R.id.sub_main).isVisible = false + showLogout = false + invalidateOptionsMenu() + } + + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if(hasFocus) { + setListView() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + menu.findItem(R.id.action_logout).isVisible = showLogout + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_settings -> { + val intent = Intent(this, SettingsActivity::class.java) + startActivity(intent) + return true + } + R.id.action_restart -> { + restart() + return true + } + R.id.action_logout -> { + logout() + return true + } + + else -> super.onOptionsItemSelected(item) + } + } + + private fun restart() { + Log.d(TAG, "Restarting the Listener") + val serviceIntent = Intent(this, StartService::class.java) + this.stopService(serviceIntent) + startListener() + } + + private fun logout() { + val alert: android.app.AlertDialog.Builder = android.app.AlertDialog.Builder( + this) + alert.setTitle(getString(R.string.logout_alert_title)) + alert.setMessage(R.string.logout_alert_content) + alert.setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + clearAllAuthTokens(this) + AccountImporter.getSharedPreferences(this) + .edit() + .remove("PREF_CURRENT_ACCOUNT_STRING") + .apply() + ApiUtils().deleteDevice(this) + showStart() + finish(); + startActivity(intent); + } + alert.setNegativeButton(getString(R.string.discard)) { dialog, _ -> dialog.dismiss() } + alert.show() + } + + private fun startListener(){ + Log.d(TAG, "Starting the Listener") + val serviceIntent = Intent(this, StartService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.startForegroundService(serviceIntent) + }else{ + this.startService(serviceIntent) + } + } + + private fun setListView(){ + listView = findViewById(R.id.applications_list) + val db = MessagingDatabase(this) + val tokenList = db.listTokens().toMutableList() + val appList = emptyArray().toMutableList() + tokenList.forEach { + appList.add(db.getPackageName(it)) + } + db.close() + listView.adapter = ArrayAdapter( + this, + android.R.layout.simple_list_item_1, + appList + ) + listView.setOnItemLongClickListener( + fun(parent: AdapterView<*>, v: View, position: Int, id: Long): Boolean { + val alert: android.app.AlertDialog.Builder = android.app.AlertDialog.Builder( + this) + alert.setTitle("Unregistering") + alert.setMessage("Are you sure to unregister ${appList[position]} ?") + alert.setPositiveButton("YES") { dialog, _ -> + sendUnregistered(this, tokenList[position]) + val db = MessagingDatabase(this) + db.unregisterApp(tokenList[position]) + db.close() + tokenList.removeAt(position) + appList.removeAt(position) + dialog.dismiss() + } + alert.setNegativeButton("NO") { dialog, _ -> dialog.dismiss() } + alert.show() + return true + } + ) + } +} diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/SettingsActivity.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/SettingsActivity.kt new file mode 100644 index 0000000..a14b2ec --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/activities/SettingsActivity.kt @@ -0,0 +1,48 @@ +package org.unifiedpush.distributor.nextpush.activities + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.EditText +import org.unifiedpush.distributor.nextpush.R +import org.unifiedpush.distributor.nextpush.distributor.MessagingDatabase +import org.unifiedpush.distributor.nextpush.distributor.getEndpoint +import org.unifiedpush.distributor.nextpush.distributor.sendEndpoint + +class SettingsActivity : AppCompatActivity() { + private var prefs: SharedPreferences? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prefs = getSharedPreferences("Config", Context.MODE_PRIVATE) + setContentView(R.layout.activity_settings) + val address = prefs?.getString("address", "") + val proxy = prefs?.getString("proxy", "") + findViewById(R.id.settings_address_value).setText(address) + findViewById(R.id.settings_proxy_value).setText(proxy) + val btn = findViewById(R.id.settings_save_button) + btn.setOnClickListener { v -> save(v) } + } + + fun save(view: View){ + val address = findViewById(R.id.settings_address_value).text.toString() + val proxy = findViewById(R.id.settings_proxy_value).text.toString() + Log.i("save",address) + val editor = prefs!!.edit() + editor.putString("address", address) + editor.putString("proxy", proxy) + editor.commit() + val db = MessagingDatabase(this) + val tokenList = db.listTokens() + db.close() + tokenList.forEach { + sendEndpoint(this, it, getEndpoint(this, it)) + } + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiResponse.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiResponse.kt new file mode 100644 index 0000000..d584e3f --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiResponse.kt @@ -0,0 +1,7 @@ +package org.unifiedpush.distributor.nextpush.api + +data class ApiResponse( + val success: Boolean = false, + val deviceId: String = "", + val token: String = "", +) diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiUtils.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiUtils.kt new file mode 100644 index 0000000..c9a33cf --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ApiUtils.kt @@ -0,0 +1,163 @@ +package org.unifiedpush.distributor.nextpush.api + +import android.content.Context +import android.os.Build +import android.util.Log +import com.google.gson.GsonBuilder +import com.nextcloud.android.sso.api.NextcloudAPI +import io.reactivex.Observer +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.sse.EventSource +import okhttp3.sse.EventSources +import org.unifiedpush.distributor.nextpush.account.getDeviceId +import org.unifiedpush.distributor.nextpush.account.removeDeviceId +import org.unifiedpush.distributor.nextpush.account.saveDeviceId +import org.unifiedpush.distributor.nextpush.account.ssoAccount +import org.unifiedpush.distributor.nextpush.api.ProviderApi.Companion.mApiEndpoint +import org.unifiedpush.distributor.nextpush.services.SSEListener +import retrofit2.NextcloudRetrofitApiBuilder +import java.util.concurrent.TimeUnit + +private const val TAG = "ApiUtils" + +class ApiUtils { + private lateinit var mApi: ProviderApi + private lateinit var nextcloudAPI: NextcloudAPI + private lateinit var factory: EventSource.Factory + + fun destroy() { + if (this::nextcloudAPI.isInitialized) + nextcloudAPI.stop() + } + + private fun cApi(context: Context, callback: ()->Unit) { + if (this::mApi.isInitialized and this::nextcloudAPI.isInitialized) { + callback() + } else { + val callback = object : NextcloudAPI.ApiConnectedListener { + override fun onConnected() { + Log.d(TAG, "Api connected.") + callback() + } + override fun onError(ex: Exception) { + Log.d(TAG, "Cannot connect to API: ex = [$ex]") + } + } + nextcloudAPI = NextcloudAPI(context, ssoAccount, GsonBuilder().create(), callback) + mApi = NextcloudRetrofitApiBuilder(nextcloudAPI, mApiEndpoint) + .create(ProviderApi::class.java) + } + } + + fun sync(context: Context) { + cApi(context) { cSync(context) } + } + + private fun cSync(context: Context) { + var deviceId = getDeviceId(context) + // Register the device if it is not yet registered + if (deviceId.isNullOrEmpty()) { + Log.d(TAG, "No deviceId found.") + val parameters: MutableMap = HashMap() + parameters["deviceName"] = Build.MODEL + mApi.createDevice(parameters) + ?.subscribeOn(Schedulers.newThread()) + ?.observeOn(Schedulers.newThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + Log.d(TAG, "onSubscribe") + } + + override fun onNext(response: ApiResponse) { + val deviceIdentifier: String = response.deviceId + Log.d(TAG, "Device Identifier: $deviceIdentifier") + saveDeviceId(context,deviceIdentifier) + deviceId = deviceIdentifier + } + + override fun onError(e: Throwable) { + e.printStackTrace() + } + + override fun onComplete() { + // Sync once it is registered + cSync(deviceId!!) + Log.d(TAG, "mApi register: onComplete") + } + }) + } else { + // Sync directly + Log.d(TAG, "Found deviceId: $deviceId") + cSync(deviceId!!) + } + } + + + private fun cSync(deviceId: String) { + val client = OkHttpClient.Builder() + .readTimeout(0, TimeUnit.SECONDS) + .retryOnConnectionFailure(false) + .build() + val url = "${ssoAccount.url}$mApiEndpoint/device/$deviceId" + + val request = Request.Builder().url(url) + .get() + .build() + + factory = EventSources.createFactory(client) + factory.newEventSource(request, SSEListener()) + Log.d(TAG, "doConnect done.") + } + + fun deleteDevice(context: Context) { + cApi(context) { cDeleteDevice(context) } + } + + private fun cDeleteDevice(context: Context) { + var deviceId = getDeviceId(context) + + mApi.deleteDevice(deviceId) + ?.subscribeOn(Schedulers.newThread()) + ?.observeOn(Schedulers.newThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + Log.d(TAG, "Subscribed to deleteDevice.") + } + + override fun onNext(response: ApiResponse) { + if (response.success) { + Log.d(TAG, "Device successfully deleted.") + } else { + Log.d(TAG, "An error occurred while deleting the device registration.") + } + } + + override fun onError(e: Throwable) { + e.printStackTrace() + } + + override fun onComplete() { + } + }) + removeDeviceId(context) + } + + fun createApp(context: Context) { + cApi(context) { cCreateApp(context) } + } + + private fun cCreateApp(context: Context) { + + } + + fun deleteApp(context: Context) { + cApi(context) { cDeleteApp(context) } + } + + private fun cDeleteApp(context: Context) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ProviderApi.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ProviderApi.kt new file mode 100644 index 0000000..bcdc508 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/ProviderApi.kt @@ -0,0 +1,34 @@ +package org.unifiedpush.distributor.nextpush.api + +import io.reactivex.Observable; +import retrofit2.http.PUT +import retrofit2.http.GET +import retrofit2.http.DELETE +import retrofit2.http.Body +import retrofit2.http.Path + +interface ProviderApi { + + @PUT("/device/") + fun createDevice( + @Body subscribeMap: MutableMap? + ): Observable? + + @GET("/device/{deviceId}") + fun syncDevice(@Path("deviceId") devideId: String?): Observable? + + @DELETE("/device/{deviceId}") + fun deleteDevice(@Path("deviceId") devideId: String?): Observable? + + @PUT("/app/") + fun createApp( + @Body authorizeMap: MutableMap? + ): Observable? + + @DELETE("/app/{token}") + fun deleteApp(@Path("token") token: String?): Observable? + + companion object { + const val mApiEndpoint = "/index.php/apps/uppush" + } +} diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/api/SSEResponse.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/SSEResponse.kt new file mode 100644 index 0000000..35e24e6 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/api/SSEResponse.kt @@ -0,0 +1,7 @@ +package org.unifiedpush.distributor.nextpush.api + +data class SSEResponse ( + val type: String = "", + val token: String = "", + val message: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/DistributorUtils.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/DistributorUtils.kt new file mode 100644 index 0000000..76dd1a4 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/DistributorUtils.kt @@ -0,0 +1,66 @@ +package org.unifiedpush.distributor.nextpush.distributor + +import android.content.Context +import android.content.Intent +import android.util.Log + +/** + * These functions are used to send messages to other apps + */ + +fun sendMessage(context: Context, token: String, message: String){ + val application = getApp(context, token) + if (application.isNullOrBlank()) { + return + } + val broadcastIntent = Intent() + broadcastIntent.`package` = application + broadcastIntent.action = ACTION_MESSAGE + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_MESSAGE, message) + context.sendBroadcast(broadcastIntent) +} + +fun sendEndpoint(context: Context, token: String, endpoint: String) { + val application = getApp(context, token) + if (application.isNullOrBlank()) { + return + } + val broadcastIntent = Intent() + broadcastIntent.`package` = application + broadcastIntent.action = ACTION_NEW_ENDPOINT + broadcastIntent.putExtra(EXTRA_TOKEN, token) + broadcastIntent.putExtra(EXTRA_ENDPOINT, endpoint) + context.sendBroadcast(broadcastIntent) +} + +fun sendUnregistered(context: Context, token: String) { + val application = getApp(context, token) + if (application.isNullOrBlank()) { + return + } + val broadcastIntent = Intent() + broadcastIntent.`package` = application + broadcastIntent.action = ACTION_UNREGISTERED + broadcastIntent.putExtra(EXTRA_TOKEN, token) + context.sendBroadcast(broadcastIntent) +} + +fun getApp(context: Context, token: String): String?{ + val db = MessagingDatabase(context) + val app = db.getPackageName(token) + db.close() + return if (app.isBlank()) { + Log.w("notifyClient", "No app found for $token") + null + } else { + app + } +} + +fun getEndpoint(context: Context, appToken: String): String { + val settings = context.getSharedPreferences("Config", Context.MODE_PRIVATE) + val address = settings?.getString("address","") + return settings?.getString("proxy","") + + "/foo/$appToken/" +} \ No newline at end of file diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/MessagingDatabase.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/MessagingDatabase.kt new file mode 100644 index 0000000..f0df101 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/MessagingDatabase.kt @@ -0,0 +1,100 @@ +package org.unifiedpush.distributor.nextpush.distributor + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +private const val DB_NAME = "apps_db" +private const 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," + + "token TEXT," + + "PRIMARY KEY (token));" + private val TABLE_APPS = "apps" + private val FIELD_PACKAGE_NAME = "package_name" + 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, token: String) { + val db = writableDatabase + val values = ContentValues().apply { + put(FIELD_PACKAGE_NAME, packageName) + put(FIELD_TOKEN, token) + } + db.insert(TABLE_APPS, null, values) + } + + fun unregisterApp(token: String) { + val db = writableDatabase + val selection = "$FIELD_TOKEN = ?" + val selectionArgs = arrayOf(token) + db.delete(TABLE_APPS, selection, selectionArgs) + } + + fun isRegistered(packageName: String, token: String): Boolean { + val db = readableDatabase + val selection = "$FIELD_PACKAGE_NAME = ? AND $FIELD_TOKEN = ?" + val selectionArgs = arrayOf(packageName, token) + return db.query( + TABLE_APPS, + null, + selection, + selectionArgs, + null, + null, + null + ).use { cursor -> + (cursor != null && cursor.count > 0) + } + } + + fun getPackageName(token: String): String { + val db = readableDatabase + val projection = arrayOf(FIELD_PACKAGE_NAME) + val selection = "$FIELD_TOKEN = ?" + val selectionArgs = arrayOf(token) + return db.query( + TABLE_APPS, + projection, + selection, + selectionArgs, + null, + null, + null + ).use { cursor -> + val column = cursor.getColumnIndex(FIELD_PACKAGE_NAME) + if (cursor.moveToFirst() && column >= 0) cursor.getString(column) else "" + } + } + + fun listTokens(): List { + val db = readableDatabase + val projection = arrayOf(FIELD_TOKEN) + return db.query( + TABLE_APPS, + projection, + null, + null, + null, + null, + null + ).use{ cursor -> + generateSequence { if (cursor.moveToNext()) cursor else null } + .mapNotNull{ + val column = cursor.getColumnIndex(FIELD_TOKEN) + if (column >= 0) it.getString(column) else null } + .toList() + } + } +} diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/UnifiedPushConstants.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/UnifiedPushConstants.kt new file mode 100644 index 0000000..9d94140 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/distributor/UnifiedPushConstants.kt @@ -0,0 +1,22 @@ +package org.unifiedpush.distributor.nextpush.distributor + +/** + * Constants as defined on the specs + * https://github.com/UnifiedPush/UP-spec/blob/main/specifications.md + */ + +const val ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT" +const val ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED" +const val ACTION_REGISTRATION_REFUSED = "org.unifiedpush.android.connector.REGISTRATION_REFUSED" +const val ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED" +const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE" + +const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER" +const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER" +const val ACTION_MESSAGE_ACK = "org.unifiedpush.android.distributor.MESSAGE_ACK" + +const val EXTRA_APPLICATION = "application" +const val EXTRA_TOKEN = "token" +const val EXTRA_ENDPOINT = "endpoint" +const val EXTRA_MESSAGE = "message" +const val EXTRA_MESSAGE_ID = "id" diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/RegisterBroadcastReceiver.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/RegisterBroadcastReceiver.kt new file mode 100644 index 0000000..faa4eb5 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/RegisterBroadcastReceiver.kt @@ -0,0 +1,65 @@ +package org.unifiedpush.distributor.nextpush.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import org.unifiedpush.distributor.nextpush.distributor.* +import kotlin.concurrent.thread + +/** + * THIS SERVICE IS USED BY OTHER APPS TO REGISTER + */ + +class RegisterBroadcastReceiver : BroadcastReceiver() { + + private fun unregisterApp(db: MessagingDatabase, application: String, token: String) { + Log.i("RegisterService","Unregistering $application token: $token") + db.unregisterApp(token) + } + + private fun registerApp(context: Context?, db: MessagingDatabase, application: String, token: String) { + if (application.isBlank()) { + Log.w("RegisterService","Trying to register an app without packageName") + return + } + Log.i("RegisterService","registering $application token: $token") + // The app is registered with the same token : we re-register it + // the client may need its endpoint again + if (db.isRegistered(application, token)) { + Log.i("RegisterService","$application already registered") + return + } + + db.registerApp(application, token) + } + + override fun onReceive(context: Context?, intent: Intent?) { + when (intent!!.action) { + ACTION_REGISTER ->{ + Log.i("Register","REGISTER") + val token = intent.getStringExtra(EXTRA_TOKEN)?: "" + val application = intent.getStringExtra(EXTRA_APPLICATION)?: "" + thread(start = true) { + val db = MessagingDatabase(context!!) + registerApp(context, db, application, token) + db.close() + Log.i("RegisterService","Registration is finished") + }.join() + sendEndpoint(context!!, token, getEndpoint(context, token)) + } + ACTION_UNREGISTER ->{ + Log.i("Register","UNREGISTER") + val token = intent.getStringExtra(EXTRA_TOKEN)?: "" + val application = intent.getStringExtra(EXTRA_APPLICATION)?: "" + thread(start = true) { + val db = MessagingDatabase(context!!) + unregisterApp(db,application, token) + db.close() + Log.i("RegisterService","Unregistration is finished") + } + sendUnregistered(context!!, token) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/StartReceiver.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/StartReceiver.kt new file mode 100644 index 0000000..055b934 --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/receivers/StartReceiver.kt @@ -0,0 +1,23 @@ +package org.unifiedpush.distributor.nextpush.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import org.unifiedpush.distributor.nextpush.services.StartService + +class StartReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + Intent(context, StartService::class.java).also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(it) + return + } + context.startService(it) + } + } + } +} + diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/services/SSEListener.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/services/SSEListener.kt new file mode 100644 index 0000000..357d15b --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/services/SSEListener.kt @@ -0,0 +1,55 @@ +package org.unifiedpush.distributor.nextpush.services + +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSource +import android.util.Log +import okhttp3.Response +import java.lang.Exception + +private const val TAG = "SSEListener" + +class SSEListener : EventSourceListener() { + private var pingtime = 0.toLong() + private val networkConnected = false + + override fun onOpen(eventSource: EventSource, response: Response) { + pingtime = System.currentTimeMillis() + try { + Log.d(TAG, "onOpen: " + response.code) + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun onEvent(eventSource: EventSource, id: String?, eventType: String?, data: String) { + Log.d(TAG, "New SSE message event=$eventType message=$data") + pingtime = System.currentTimeMillis() + if (eventType == "warning") { + Log.d(TAG, "Warning event received.") + // Notification warning + } + if (eventType == "ping") { + Log.d(TAG, "SSE ping received.") + } + if (eventType != "notification") return + Log.d(TAG, "Notification event received.") + // handle notification + } + + override fun onClosed(eventSource: EventSource) { + Log.d(TAG, "onClosed: $eventSource") + if (!networkConnected) return + } + + override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) { + t?.let { + Log.d(TAG, "An error occurred: $t") + return + } + response?.let { + Log.d(TAG, "onFailure: ${it.code}") + if (!networkConnected) return + return + } + } +} diff --git a/app/src/main/java/org/unifiedpush/distributor/nextpush/services/StartService.kt b/app/src/main/java/org/unifiedpush/distributor/nextpush/services/StartService.kt new file mode 100644 index 0000000..3b5319b --- /dev/null +++ b/app/src/main/java/org/unifiedpush/distributor/nextpush/services/StartService.kt @@ -0,0 +1,106 @@ +package org.unifiedpush.distributor.nextpush.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log + +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.nextcloud.android.sso.ui.UiExceptionManager + +import org.unifiedpush.distributor.nextpush.R +import org.unifiedpush.distributor.nextpush.api.ApiUtils +import org.unifiedpush.distributor.nextpush.account.ssoAccount + +private const val TAG = "StartService" + +class StartService: Service(){ + + private var isServiceStarted = false + private var wakeLock: PowerManager.WakeLock? = null + private var api = ApiUtils() + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate(){ + super.onCreate() + Log.i(TAG,"Starting") + val notification = createNotification() + startForeground(51115, notification) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startService() + // by returning this we make sure the service is restarted if the system kills the service + return START_STICKY + } + + override fun onDestroy() { + api.destroy() + super.onDestroy() + } + + private fun createNotification(): Notification { + val appName = getString(R.string.app_name) + val notificationChannelId = "$appName.Listener" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + val channel = NotificationChannel( + notificationChannelId, + appName, + NotificationManager.IMPORTANCE_LOW + ).let { + it.description = getString(R.string.listening_notif_description) + it + } + notificationManager.createNotificationChannel(channel) + } + + val builder: Notification.Builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder( + this, + notificationChannelId + ) else Notification.Builder(this) + + return builder + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.listening_notif_description)) + .setSmallIcon(R.drawable.ic_launcher_notification) + .setTicker("Listening") + .setPriority(Notification.PRIORITY_LOW) // for under android 26 compatibility + .build() + } + + private fun startService() { + if (isServiceStarted) return + isServiceStarted = true + + try { + ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(this) + } catch (e: NextcloudFilesAppAccountNotFoundException) { + UiExceptionManager.showDialogForException(this, e) + } catch (e: NoCurrentAccountSelectedException) { + return + } + + // we need this lock so our service gets not affected by Doze Mode + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { + acquire() + } + } + + api.sync(this) + } +} + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..77ef5fb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_notification.xml b/app/src/main/res/drawable/ic_launcher_notification.xml new file mode 100644 index 0000000..6c1cbc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_notification.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..f2c2a52 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..14eb778 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + +