commit a4c68ca9e25e0ad2f7c82fe94e6557d930599f42 Author: Gergely Hegedus Date: Sat Mar 30 21:51:25 2024 +0200 init diff --git a/.github/workflows/deploy_to_playstore.yml b/.github/workflows/deploy_to_playstore.yml new file mode 100644 index 0000000..3b94cc4 --- /dev/null +++ b/.github/workflows/deploy_to_playstore.yml @@ -0,0 +1,64 @@ +name: Deploy to Play Store Internal + +on: + workflow_dispatch: + inputs: + skipBuildNumberIncrease: + description: 'Skip automatic build number increase' + type: boolean + default: false + release: + types: [published] + +jobs: + android-publish-to-play-store: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: ./android + env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps + BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Ruby & Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.1' + bundler-cache: true + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'adopt' + java-version: '17' + # caching currently disabled, because the files are large, but don't decrease built time too much + #cache: gradle + + - name: Restore Release Keystore + env: + keystore_base64: ${{ secrets.PNS_ANDROID_KEYSTORE_BASE64 }} + run: | + echo "$keystore_base64" | base64 --decode > release.keystore + echo "PNS_KEYSTORE=`pwd`/release.keystore" >> $GITHUB_ENV + - name: Restore PlayStore Service Account + env: + play_store_service_account_json: ${{ secrets.PNS_ANDROID_PLAY_STORE_AUTH_JSON }} + run: | + echo "$play_store_service_account_json" > play-store-distribution-service-account.json + echo "PNS_ANDROID_PLAY_STORE_AUTH_FILE=`pwd`/play-store-distribution-service-account.json" >> $GITHUB_ENV + - name: Restore google-services.json + env: + android_google_service_json: ${{ secrets.PNS_ANDROID_GOOGLE_SERVICE_JSON }} + run: | + echo "$android_google_service_json" > app/google-services.json + + - name: Deploy to Play Store Internal Track + env: + PNS_KEY: ${{ secrets.PNS_ANDROID_KEYSTORE_KEY_ALIAS }} + PNS_PASS: ${{ secrets.PNS_ANDROID_KEYSTORE_KEY_PASSWORD }} + PNS_KEY_PASS: ${{ secrets.PNS_ANDROID_KEYSTORE_STORE_PASSWORD }} + PNS_BASE_URL: ${{ secrets.PNS_BASE_URL }} + run: bundle exec fastlane deployProdToPlayStore skip_build_number_increase:${{ github.event.inputs.skipBuildNumberIncrease }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa10139 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +fastlane/report.xml +/build +/captures +.externalNativeBuild +.cxx +local.properties +pns.jks +pns.signingconfig +play_store_auth.json diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 0000000..2f81756 --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +ServerNotifications \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/google-java-format.xml b/android/.idea/google-java-format.xml new file mode 100644 index 0000000..2aa056d --- /dev/null +++ b/android/.idea/google-java-format.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..103e00c --- /dev/null +++ b/android/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 0000000..0ad17cb --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/Gemfile b/android/Gemfile new file mode 100644 index 0000000..cdd3a6b --- /dev/null +++ b/android/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "fastlane" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/android/Gemfile.lock b/android/Gemfile.lock new file mode 100644 index 0000000..d8f1b55 --- /dev/null +++ b/android/Gemfile.lock @@ -0,0 +1,225 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.815.0) + aws-sdk-core (3.181.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.134.0) + aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.102.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.214.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + fastlane-plugin-firebase_app_distribution (0.7.2) + google-apis-firebaseappdistribution_v1 (~> 0.3.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.49.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-firebaseappdistribution_v1 (0.3.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.44.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.7.0) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + memoist (0.16.2) + mini_magick (4.12.0) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.3) + rake (13.0.6) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (1.8.0) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.22.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-21 + arm64-darwin-22 + x86_64-linux + +DEPENDENCIES + fastlane + fastlane-plugin-firebase_app_distribution + +BUNDLED WITH + 2.4.10 diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..65d12b9 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build +google-services.json \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..8bf86a3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,103 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.googleServices) + alias(libs.plugins.serialization) +} + +// custom properties +val applicationIdArgument = project.findProperty("applicationId")?.toString() +val applicationVersionCodeArgument = project.findProperty("versionCode")?.toString()?.toIntOrNull() +val baseUrlArgument = System.getenv("PNS_BASE_URL") ?: "http://127.0.0.1:8080/" + +android { + namespace = applicationIdArgument ?: "org.fnives.android.servernotifications" + compileSdk = 34 + + defaultConfig { + applicationId = applicationIdArgument ?: "org.fnives.android.servernotifications" + minSdk = 29 + targetSdk = 34 + versionCode = applicationVersionCodeArgument ?: 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + buildConfigField("String", "BASE_URL", "\"$baseUrlArgument\"") + } + + signingConfigs { + with(maybeCreate("debug")) { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + with(maybeCreate("release")) { + storeFile = file(System.getenv("PNS_KEYSTORE") ?: "debug.keystore") + keyAlias = System.getenv("PNS_KEY") ?: "" + keyPassword = System.getenv("PNS_KEY_PASS") ?: "" + storePassword = System.getenv("PNS_PASS") ?: "" + + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.fragment) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + implementation(libs.serialization.json) + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/android/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/android/app/src/androidTest/java/org/fnives/android/servernotifications/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/org/fnives/android/servernotifications/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..8ab0abc --- /dev/null +++ b/android/app/src/androidTest/java/org/fnives/android/servernotifications/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.fnives.android.servernotifications + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.fnives.android.servernotifications", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..375a2f9 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/FirebaseMessages.kt b/android/app/src/main/java/org/fnives/android/servernotifications/FirebaseMessages.kt new file mode 100644 index 0000000..c870422 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/FirebaseMessages.kt @@ -0,0 +1,155 @@ +package org.fnives.android.servernotifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import java.time.Instant +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.fnives.android.servernotifications.message.DecryptMessage +import org.fnives.android.servernotifications.message.EncryptedMessage +import org.fnives.android.servernotifications.message.Message +import org.fnives.android.servernotifications.message.MessageStorage +import org.fnives.android.servernotifications.message.Priority +import org.fnives.android.servernotifications.message.Priority.High +import org.fnives.android.servernotifications.message.Priority.Low +import org.fnives.android.servernotifications.message.Priority.Medium +import org.fnives.android.servernotifications.message.Priority.Undefined +import org.fnives.android.servernotifications.message.PriorityOf + +class FirebaseMessages : FirebaseMessagingService() { + + private val decryptMessage by lazy { DecryptMessage(this) } + private val messageStorage by lazy { MessageStorage(this) } + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onNewToken(token: String) { + super.onNewToken(token) + showNotification( + Message( + priority = High, + serviceName = "PN Token has changed!", + timestamp = Instant.now(), + message = "", + ) + ) + } + + override fun onMessageSent(msgId: String) { + super.onMessageSent(msgId) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + println("received message") + val sessionKey = remoteMessage.data["session_key"] + val iv = remoteMessage.data["iv"] + val messageData = remoteMessage.data["message"] + val priority = PriorityOf(remoteMessage.data["priority"]) + val serviceName = remoteMessage.data["service"] ?: "Undefined" + + val encryptedMessage = EncryptedMessage( + iv = iv ?: return log("IV not found in message"), + sessionKey = sessionKey ?: return log("SessionKey not found in message"), + data = messageData ?: return log("MessageData not found in message"), + ) + + coroutineScope.launch { + val decrypted = decryptMessage.decrypt(encryptedMessage) ?: return@launch + val message = Message( + message = decrypted, + priority = priority, + serviceName = serviceName, + timestamp = Instant.now() + ) + messageStorage.addMessage(message) + showNotification(message) + } + } + + private fun showNotification(message: Message) { + val importance = when (message.priority) { + High -> NotificationCompat.PRIORITY_HIGH + Medium -> NotificationCompat.PRIORITY_DEFAULT + Low -> NotificationCompat.PRIORITY_LOW + Undefined -> NotificationCompat.PRIORITY_DEFAULT + } + showNotification( + importance = importance, + channelId = message.priority.getChannelId(), + title = "${message.priority.iconText()} ${message.serviceName}", + id = message.hashCode() + ) + } + + private fun Priority.getChannelId(): String { + val importance = when (this) { + High -> NotificationManager.IMPORTANCE_HIGH + Medium -> NotificationManager.IMPORTANCE_DEFAULT + Low -> NotificationManager.IMPORTANCE_LOW + Undefined -> NotificationManager.IMPORTANCE_DEFAULT + } + createChannel( + name = name, + description = "$name priority status updates from server", + importance = importance, + id = name + ) + + return name + } + + private fun showNotification( + importance: Int, title: String, channelId: String, id: Int, + intent: Intent = Intent(this, MainActivity::class.java), + ) { + val builder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.ic_server) + .setContentTitle(title) + .setPriority(importance) + .setContentIntent( + PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .setAutoCancel(true) + + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(this).notify(id, builder.build()) + } + } + + private fun createChannel( + name: String, + description: String, + id: String, + importance: Int = NotificationManager.IMPORTANCE_DEFAULT + ) { + val channel = NotificationChannel(id, name, importance).apply { + this.description = description + } + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun log(message: String) { + Log.d("FirebaseMessages", message) + } +} + diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/MainActivity.kt b/android/app/src/main/java/org/fnives/android/servernotifications/MainActivity.kt new file mode 100644 index 0000000..8ef7642 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/MainActivity.kt @@ -0,0 +1,249 @@ +package org.fnives.android.servernotifications + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Space +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.fnives.android.servernotifications.message.MessageStorage +import org.fnives.android.servernotifications.message.Priority +import org.fnives.android.servernotifications.message.Priority.High +import org.fnives.android.servernotifications.message.Priority.Low +import org.fnives.android.servernotifications.message.Priority.Medium +import org.fnives.android.servernotifications.message.Priority.Undefined +import org.fnives.android.servernotifications.token.TokenWebView +import org.fnives.android.servernotifications.ui.theme.ServerNotificationsTheme +import org.fnives.android.servernotifications.url.UrlStorage + +class MainActivity : ComponentActivity() { + + private val grantedEventFlow = MutableSharedFlow(extraBufferCapacity = 1) + val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.POST_NOTIFICATIONS + } else { + null + } + private val grantedFlow = grantedEventFlow.onStart { emit(Unit) } + .map { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.checkSelfPermission( + this, + notificationPermission!! + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { _: Boolean -> + println(grantedEventFlow.tryEmit(Unit)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val messageStorage = MessageStorage(this) + val urlStorage = UrlStorage(this) + val formatter = DateTimeFormatter.ofPattern("MMM d yyyy hh:mm a") + .withLocale(Locale.getDefault()) + .withZone(ZoneId.of("UTC")) + setContent { + ServerNotificationsTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + var navigationState by remember { mutableStateOf(Navigation.Home) } + BackHandler(navigationState != Navigation.Home) { + navigationState = Navigation.Home + } + + when (navigationState) { + Navigation.Administration -> { + TokenWebView(url = urlStorage.current) + } + + Navigation.Home -> { + val grantedState by grantedFlow.collectAsState(initial = null) + if (grantedState == true) { + val scope = rememberCoroutineScope() + Column { + AdministrationNotice( + onShowRegistration = { + navigationState = Navigation.Administration + }, + onShowChangeUrl = { + navigationState = Navigation.ChangeURL + }, + onClearLogs = { scope.launch { messageStorage.clear() } }) + Logs(messageStorage, formatter) + } + } else if (grantedState == false) { + NoPermission() + } + } + + Navigation.ChangeURL -> { + ChangeURL(urlStorage) + } + } + } + } + } + } + + @Composable + fun NoPermission() { + Box(modifier = Modifier.fillMaxSize()) { + Button( + modifier = Modifier.align(Alignment.Center), + onClick = { + requestPermissionLauncher.launch(notificationPermission) + }) { + Text(text = "Grant notification permission") + } + } + } + + @Composable + fun AdministrationNotice( + onShowRegistration: () -> Unit, + onShowChangeUrl: () -> Unit, + onClearLogs: () -> Unit, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), Arrangement.Center + ) { + Row { + Button( + onClick = { onShowRegistration() }, + ) { + Text(text = "Administration") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { onShowChangeUrl() }, + ) { + Text(text = "Change URL") + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { onClearLogs() }) { + Text(text = "Clear logs") + } + } + } + + @Composable + fun Logs(messageStorage: MessageStorage, formatter: DateTimeFormatter) { + val messages by messageStorage.messages.collectAsState(initial = listOf()) + LazyColumn { + messages.forEach { + item { + Text( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + text = "${it.priority.iconText()} ${it.serviceName}: ${it.message} at ${ + formatter.format( + it.timestamp + ) + }" + ) + } + } + } + } + @Composable + fun ChangeURL(urlStorage: UrlStorage) { + val current by urlStorage.currentUrl.collectAsState(initial = "") + val options by urlStorage.urlOptions.collectAsState(initial = emptySet()) + var overwrite by remember { mutableStateOf("") } + val keyboardOptions = remember{KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done)} + Column { + Text(text = "Current", style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = current, style = MaterialTheme.typography.bodyLarge) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Overwrite", style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.height(4.dp)) + TextField( + overwrite, + textStyle = MaterialTheme.typography.bodyLarge, + onValueChange = { overwrite = it }, + keyboardActions = KeyboardActions(onDone = { + urlStorage.selectedUrl(overwrite) + defaultKeyboardAction(ImeAction.Done) + }), + keyboardOptions = keyboardOptions + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Recent", style = MaterialTheme.typography.labelMedium) + options.forEach { + Button(onClick = { + urlStorage.selectedUrl(it) + }) { + Text(text = it, style = MaterialTheme.typography.bodyMedium) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +fun Priority.iconText(): String { + return when (this) { + High -> "\uD83D\uDEA8" + Medium -> "⚠\uFE0F" + Low -> "ℹ\uFE0F" + Undefined -> "\uD83E\uDD14" + } +} + +enum class Navigation { + Administration, Home, ChangeURL +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/message/DecryptMessage.kt b/android/app/src/main/java/org/fnives/android/servernotifications/message/DecryptMessage.kt new file mode 100644 index 0000000..36482dd --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/message/DecryptMessage.kt @@ -0,0 +1,102 @@ +package org.fnives.android.servernotifications.message + +import android.content.Context +import org.fnives.android.servernotifications.BuildConfig +import java.io.File +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + + +interface DecryptMessage { + + fun decrypt(message: EncryptedMessage): String? +} + +fun DecryptMessage(context: Context): DecryptMessage { + return DecryptMessageImpl(getKeyPair(context)) +} + +fun getKeyPair(context: Context): KeyPair { + val publicKeyFile = File(context.filesDir, "key.pub") + val privateKeyFile = File(context.filesDir, "key.rsa") + val publicKeyBytes = if (publicKeyFile.exists()) publicKeyFile.readBytes() else ByteArray(0) + val privateKeyBytes = if (privateKeyFile.exists()) privateKeyFile.readBytes() else ByteArray(0) + + val keyPair: KeyPair + if (publicKeyBytes.isEmpty() || privateKeyBytes.isEmpty()) { + keyPair = DecryptMessageImpl.generateKeyPair() + publicKeyFile.writeBytes(keyPair.public.encoded) + privateKeyFile.writeBytes(keyPair.private.encoded) + } else { + val publicKey = + KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(publicKeyBytes)) + val privateKey = + KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(privateKeyBytes)) + + keyPair = KeyPair(publicKey, privateKey) + } + + return keyPair +} + +class DecryptMessageImpl(private val keyPair: KeyPair) : DecryptMessage { + + //from Crypto.PublicKey import RSA + //from Crypto.Cipher import AES, PKCS1_OAEP + // + //private_key = RSA.import_key(open("private.pem").read()) + // + //with open("encrypted_data.bin", "rb") as f: + // enc_session_key = f.read(private_key.size_in_bytes()) + // nonce = f.read(16) + // tag = f.read(16) + // ciphertext = f.read() + // + //# Decrypt the session key with the private RSA key + //cipher_rsa = PKCS1_OAEP.new(private_key) + //session_key = cipher_rsa.decrypt(enc_session_key) + // + //# Decrypt the data with the AES session key + //cipher_aes = AES.new(session_key, AES.MODE_EAX, nonce) + //data = cipher_aes.decrypt_and_verify(ciphertext, tag) + //print(data.decode("utf-8")) + @OptIn(ExperimentalEncodingApi::class) + override fun decrypt(message: EncryptedMessage): String? { + try { + val privateKey = keyPair.private + val rsaCipher = Cipher.getInstance("RSA/NONE/OAEPPadding") + rsaCipher.init(Cipher.DECRYPT_MODE, privateKey) + val sessionKey = rsaCipher.doFinal(Base64.decode(message.sessionKey)) + val iv = rsaCipher.doFinal(Base64.decode(message.iv)) + + val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding") + val aesKey = SecretKeySpec(sessionKey, "AES") + cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) + val decryptedByteArray = cipher.doFinal(Base64.decode(message.data)) + return decryptedByteArray.toString(StandardCharsets.UTF_8) + } catch(e: Throwable) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } + return null + } + } + + companion object { + + fun generateKeyPair(): KeyPair { + val kpg = KeyPairGenerator.getInstance("RSA") + kpg.initialize(2048) + return kpg.genKeyPair() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/message/EncryptedMessage.kt b/android/app/src/main/java/org/fnives/android/servernotifications/message/EncryptedMessage.kt new file mode 100644 index 0000000..b3c74d6 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/message/EncryptedMessage.kt @@ -0,0 +1,7 @@ +package org.fnives.android.servernotifications.message + +data class EncryptedMessage( + val iv: String, + val data: String, + val sessionKey: String +) \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/message/Message.kt b/android/app/src/main/java/org/fnives/android/servernotifications/message/Message.kt new file mode 100644 index 0000000..4a345f7 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/message/Message.kt @@ -0,0 +1,78 @@ +package org.fnives.android.servernotifications.message + +import androidx.annotation.Keep +import com.google.firebase.BuildConfig +import java.time.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json + +@Serializable +data class Message( + @SerialName("message") + val message: String, + @SerialName("priority") + val priority: Priority, + @SerialName("service") + val serviceName: String, + @SerialName("time") + @Serializable(InstantSerializer::class) + val timestamp: Instant +) + +@Keep +enum class Priority { + High, Medium, Low, Undefined +} + +@Suppress("FunctionName") +fun PriorityOf(priority: String?): Priority { + return Priority.entries.firstOrNull { it.name.equals(priority, ignoreCase = true) } + ?: Priority.Undefined +} + +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) +} + +interface Converter { + + fun serialize(message: Message): String? + fun deserialize(message: String): Message? +} + +fun Converter(): Converter { + return SerializerConverter() +} + +class SerializerConverter : Converter { + override fun serialize(message: Message): String? = + safeJson { + Json.encodeToString(message) + } + + override fun deserialize(message: String): Message? = + safeJson { + Json.decodeFromString(message) + } + + private inline fun safeJson(action: () -> T): T? { + return try { + action() + } catch (parsingException: Throwable) { + if (BuildConfig.DEBUG) { + parsingException.printStackTrace() + } + null + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/message/MessageStorage.kt b/android/app/src/main/java/org/fnives/android/servernotifications/message/MessageStorage.kt new file mode 100644 index 0000000..dda4574 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/message/MessageStorage.kt @@ -0,0 +1,72 @@ +package org.fnives.android.servernotifications.message + +import android.content.Context +import java.io.File +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +fun MessageStorage(context: Context): MessageStorage { + return MessageStorage( + limit = 100, + file = File(context.filesDir, "logs"), + converter = Converter(), + ) +} + +class MessageStorage( + private val limit: Int, + private val file: File, + private val converter: Converter, + private val context: CoroutineContext = Dispatchers.IO +) { + init { + require(limit > 0) + } + + // DO NOT DO THIS: use database in prod + val messages = writeEvent.onStart { emit(Unit) }.map { + fileMutex.withLock { + if (file.exists()) { + file.readLines() + .reversed() + .mapNotNull(converter::deserialize) + } else { + listOf() + } + } + } + + + suspend fun addMessage(message: Message) { + val serializedMessage = converter.serialize(message) ?: return + withContext(context) { + fileMutex.withLock { + val lines = if (file.exists()) file.readLines() else listOf() + val current = lines.takeLast(limit - 1) + val allLogs = current + serializedMessage + file.writeText(allLogs.joinToString("\n")) + } + } + writeEvent.tryEmit(Unit) + } + + suspend fun clear() { + withContext(context) { + fileMutex.withLock { + file.delete() + } + } + writeEvent.tryEmit(Unit) + } + + companion object { + val fileMutex = Mutex() + private val writeEvent = MutableSharedFlow(extraBufferCapacity = 1) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/token/TokenWebView.kt b/android/app/src/main/java/org/fnives/android/servernotifications/token/TokenWebView.kt new file mode 100644 index 0000000..9a98d0f --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/token/TokenWebView.kt @@ -0,0 +1,107 @@ +package org.fnives.android.servernotifications.token + +import android.R.attr.capitalize +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.annotation.Keep +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.viewinterop.AndroidView +import com.google.firebase.messaging.FirebaseMessaging +import java.util.Locale +import java.util.concurrent.CountDownLatch +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.fnives.android.servernotifications.message.getKeyPair + + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun TokenWebView(url: String) { + var showLoading by remember(url) { mutableStateOf(true) } + LaunchedEffect(url) { + // can't be bothered for a real loading indicator + delay(5000L) + showLoading = false + } + Box(Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + setBackgroundColor(0) + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) + settings.javaScriptEnabled = true + webViewClient = WebViewClient() + addJavascriptInterface(ReadTokenInterface(context), "Android") + + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + settings.setSupportZoom(true) + } + }, + update = { webView -> + webView.loadUrl(url) + } + ) + if (showLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } +} + +@Keep +class ReadTokenInterface(private val context: Context) { + + @OptIn(ExperimentalEncodingApi::class) + @JavascriptInterface + fun publicKey(): String { + val publicKey = getKeyPair(context).public.encoded + return Base64.encode(publicKey) + } + + @JavascriptInterface + fun messagingToken(): String { + val latch = CountDownLatch(1) + var token: String? = null + FirebaseMessaging.getInstance().token.addOnSuccessListener { + token = it + latch.countDown() + } + latch.await() + return token!! + } + + @JavascriptInterface + fun deviceName(): String { + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + + return if (model.startsWith(manufacturer)) { + model + } else { + "$manufacturer $model" + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Color.kt b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Color.kt new file mode 100644 index 0000000..bc64e96 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package org.fnives.android.servernotifications.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Theme.kt b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Theme.kt new file mode 100644 index 0000000..d928231 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Theme.kt @@ -0,0 +1,73 @@ +package org.fnives.android.servernotifications.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + surface = Color(0xFF2a2a2a), +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + surface = Color(0xFFEaEaEa), + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ServerNotificationsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surface.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Type.kt b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Type.kt new file mode 100644 index 0000000..8bdd127 --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package org.fnives.android.servernotifications.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/app/src/main/java/org/fnives/android/servernotifications/url/UrlStorage.kt b/android/app/src/main/java/org/fnives/android/servernotifications/url/UrlStorage.kt new file mode 100644 index 0000000..bba688b --- /dev/null +++ b/android/app/src/main/java/org/fnives/android/servernotifications/url/UrlStorage.kt @@ -0,0 +1,39 @@ +package org.fnives.android.servernotifications.url + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import org.fnives.android.servernotifications.BuildConfig + +fun UrlStorage(context: Context): UrlStorage { + val sharedPref = context.getSharedPreferences("url_storage", Context.MODE_PRIVATE) + return UrlStorage(sharedPref) +} + +class UrlStorage(private val sharedPref: SharedPreferences) { + // DO NOT DO THIS: listen to the sharedPref or use DataStore + private val update = MutableSharedFlow(extraBufferCapacity = 1, replay = 1).apply { + tryEmit(Unit) + } + private var _current get() = sharedPref.getString("current", null) ?: BuildConfig.BASE_URL + set(value) { + sharedPref.edit().putString("current", value).apply() + } + val currentUrl: Flow = update.map {_current } + val current: String get() = _current + private var _urlOptions get() = sharedPref.getStringSet("all", null) ?: setOf(BuildConfig.BASE_URL) + set(value) { + sharedPref.edit().putStringSet("all", value).apply() + } + val urlOptions: Flow> = update.map {_urlOptions } + + fun selectedUrl(url: String) { + _current = url + _urlOptions = _urlOptions + url + update.tryEmit(Unit) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..bc2e90c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..71242ac --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_server.xml b/android/app/src/main/res/drawable/ic_server.xml new file mode 100644 index 0000000..406f197 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_server.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9c3e747 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Server Notifications + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fc8da6b --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/org/fnives/android/servernotifications/ExampleUnitTest.kt b/android/app/src/test/java/org/fnives/android/servernotifications/ExampleUnitTest.kt new file mode 100644 index 0000000..1093081 --- /dev/null +++ b/android/app/src/test/java/org/fnives/android/servernotifications/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package org.fnives.android.servernotifications + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..9faf72e --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.jetbrainsKotlinAndroid) apply false + alias(libs.plugins.googleServices) apply false +} \ No newline at end of file diff --git a/android/fastlane/AppFile b/android/fastlane/AppFile new file mode 100644 index 0000000..1a5a1d5 --- /dev/null +++ b/android/fastlane/AppFile @@ -0,0 +1,2 @@ +json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one +package_name("org.fnives.android.servernotifications") diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile new file mode 100644 index 0000000..ca1ffa7 --- /dev/null +++ b/android/fastlane/Fastfile @@ -0,0 +1,70 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:android) + +platform :android do + + before_all do + PROD_APP_IDENTIFIER = "org.fnives.android.servernotifications" + + # Environment variable should be the full path to the PlayStore auth .json + PLAY_STORE_AUTH_FILE = ENV['PNS_ANDROID_PLAY_STORE_AUTH_FILE'] + end + + desc "Submit a new Production Build to Play Store" + desc "By Default it sets the version_code to last from PlayStore + 1." + desc ">Optionally version code increase can be skipped via:" + desc "```sh" + desc "[bundle exec] fastlane deployProdToPlayStore skip_build_number_increase:true" + desc "```" + lane :deployProdToPlayStore do |options| + skip_build_number_increase = options[:skip_build_number_increase] # optional, if not set, it gets the last from PlayStore then adds + 1 + package_name = PROD_APP_IDENTIFIER + + if skip_build_number_increase.nil? || skip_build_number_increase.empty? || !skip_build_number_increase + last_version_codes = google_play_track_version_codes( + track: 'internal', + json_key: PLAY_STORE_AUTH_FILE, + package_name: package_name + ) + last_version_code = last_version_codes[0] + version_code = last_version_code + 1 + end + + gradle(task: 'clean', flags: "--no-daemon") + gradle( + task: 'bundle', + build_type: 'release', + flags: "--no-daemon", + properties: { + "applicationId" => PROD_APP_IDENTIFIER, + "versionCode" => version_code + } + ) + production_aab = lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH] + mapping_file_path = lane_context[SharedValues::GRADLE_MAPPING_TXT_OUTPUT_PATH] + + upload_to_play_store( + track: 'internal', + release_status: 'draft', # can remove once app is released to the public + aab: production_aab, + json_key: PLAY_STORE_AUTH_FILE, + skip_upload_apk: true, + package_name: package_name, + mapping_paths: [mapping_file_path] + ) + end +end diff --git a/android/fastlane/Pluginfile b/android/fastlane/Pluginfile new file mode 100644 index 0000000..b18539b --- /dev/null +++ b/android/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-firebase_app_distribution' diff --git a/android/fastlane/README.md b/android/fastlane/README.md new file mode 100644 index 0000000..7fd5f2f --- /dev/null +++ b/android/fastlane/README.md @@ -0,0 +1,42 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## Android + +### android deployProdToPlayStore + +```sh +[bundle exec] fastlane android deployProdToPlayStore +``` + +Submit a new Production Build to Play Store + +By Default it sets the version_code to last from PlayStore + 1. + +>Optionally version code increase can be skipped via: + +```sh + +[bundle exec] fastlane deployProdToPlayStore skip_build_number_increase:true + +``` + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/android/fastlane/report.xml b/android/fastlane/report.xml new file mode 100644 index 0000000..873de36 --- /dev/null +++ b/android/fastlane/report.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..73caed1 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,41 @@ +[versions] +agp = "8.3.0" +kotlin = "1.9.0" +coreKtx = "1.12.0" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.7.0" +composeBom = "2023.08.00" +googleServices = "4.4.1" +firebaseBom = "32.7.4" +serialization = "1.9.22" +serializationJson = "1.6.3" +fragmentx = "1.6.2" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragmentx" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a1f56a8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Mar 18 22:22:44 EET 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a9b4cb2 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +import java.net.URI + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() +// maven { url = URI.create("https://kotlin.bintray.com/kotlinx") } + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ServerNotifications" +include(":app") diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..de481e4 --- /dev/null +++ b/readme.md @@ -0,0 +1,18 @@ +# Server sending notifications to client + +Note: The app is full of bad practices & ugly design. + +It is for personal use with minimal effort to receive notifications from a home server. + +## Server + +- Holds devices which are defined by name, token & encryption key +- Devices can be registered +- Devices can be deleted +- When an api is hit, all devices receive a push notification + +## Client + +- Registers itself to the server +- Listens to notifications +- Keeps a list of the notifications as logs diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..2af857a --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,3 @@ +instance/ +__pycache__/ + diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e7fda6a --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.9-bullseye + +# ENV setup +RUN ["python","--version"] +RUN ["apt-get", "update"] +RUN ["pip", "install", "uwsgi"] +RUN ["uwsgi", "--version"] +RUN ["pip", "install", "flask"] +RUN ["pip", "install", "passlib"] +RUN ["pip", "install", "firebase_admin"] +RUN ["pip", "install", "pycryptodome"] +RUN ["pip", "install", "watchdog"] + +RUN adduser --system --no-create-home flask +USER flask + +WORKDIR /home/flask/server + +# load source files +COPY ./application /home/flask/server + +CMD uwsgi --ini notification-service.ini --touch-reload=notification-service.ini \ No newline at end of file diff --git a/server/application/backend/__init__.py b/server/application/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/application/backend/config.json b/server/application/backend/config.json new file mode 100644 index 0000000..1eda8b7 --- /dev/null +++ b/server/application/backend/config.json @@ -0,0 +1,5 @@ +{ + "DATABASE_NAME": "sqlitedb", + "TOKEN_SHOW_LIMIT": 4, + "FIREBASE_JSON": "instance/servernotification-767d6-4d8506505911.json" +} \ No newline at end of file diff --git a/server/application/backend/data/__init__.py b/server/application/backend/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/application/backend/data/dao_device.py b/server/application/backend/data/dao_device.py new file mode 100644 index 0000000..d6a46d7 --- /dev/null +++ b/server/application/backend/data/dao_device.py @@ -0,0 +1,40 @@ +from .data_models import Device +from .data_models import DataError +from .db import get_cursor +from sqlite3 import IntegrityError + +def _device_from_row(row): + return Device( + name = row['device_name'], + token = row['device_token'], + encryption_key = row['encryption_key'], + ) + + +def get_devices(): + with get_cursor() as db_cursor: + db_cursor.execute("SELECT * FROM device") + rows = db_cursor.fetchall() + + return map(_device_from_row, rows) + +_INSERT_DEVICE_SQL = "INSERT INTO device(device_name, device_token, encryption_key)"\ +"VALUES(:device_name, :device_token, :encryption_key)" +def insert_device(device: Device): + params = { + "device_name": device.name, + "device_token": device.token, + "encryption_key": device.encryption_key, + } + with get_cursor() as db_cursor: + try: + db_cursor.execute(_INSERT_DEVICE_SQL, params) + except IntegrityError as e: + return DataError.DEVICE_INSERT_ERROR + + return db_cursor.lastrowid + +def delete_device_by_name(name: str): + with get_cursor() as db_cursor: + db_cursor.execute('DELETE FROM device WHERE device_name=:name',{'name':name}) + diff --git a/server/application/backend/data/data_models.py b/server/application/backend/data/data_models.py new file mode 100644 index 0000000..696deee --- /dev/null +++ b/server/application/backend/data/data_models.py @@ -0,0 +1,32 @@ +from enum import Enum +from enum import IntEnum + +class Device: + def __init__(self, token, encryption_key, name): + self.token = token + self.encryption_key = encryption_key + self.name = name + + def __eq__(self, other): + if not isinstance(other, Device): + return False + return self.token == other.token \ + and self.encrpytion_key == other.encryption_key \ + and self.name == other.mname + + def __str__(self): + return 'Device(token={},encryption_key={},name={})'.format(self.token, self.encryption_key, self.name) + + def __repr__(self): + return self.__str__ + + +class DataError(Enum): + DEVICE_INSERT_ERROR = -1 + +class ResponseCode(IntEnum): + EMPTY_DEVICE_TOKEN = 401 + EMPTY_DEVICE_NAME = 402 + EMPTY_DEVICE_ENCRYPTION = 403 + DEVICE_SAVE_FAILURE = 404 + NOTIFICATION_PARAMS_MISSING = 405 diff --git a/server/application/backend/data/db.py b/server/application/backend/data/db.py new file mode 100644 index 0000000..8c88298 --- /dev/null +++ b/server/application/backend/data/db.py @@ -0,0 +1,62 @@ +import sqlite3 +from os import path +from flask import current_app, g +from contextlib import contextmanager + +@contextmanager +def get_cursor(): + db = get_db() + cursor = db.cursor() + try: + yield (cursor) + finally: + cursor.close() + db.commit() + +default_database_name = "sqlitedb" + +def get_db(): + current_app.config.get('DATABASE_PATH') + if 'db' not in g: + db_path = current_app.config.get('DATABASE_PATH') + if (db_path is None): + db_path = path.join(current_app.instance_path, current_app.config['DATABASE_NAME']) + g.db = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + g.db.row_factory = sqlite3.Row + + return g.db + +def close_db(e=None): + db = g.pop('db', None) + + if db is not None: + db.close() + +def init_db(db_path = None, schema_path = None): + if db_path is None: + db = get_db() + else: + db = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + + if schema_path is None: + with current_app.open_resource('data/schema.sql') as f: + script = f.read().decode('UTF-8') + else: + with open(schema_path, "r") as f: + script = f.read() + + db.executescript(script) + db.commit() + db.close() + +def init_app(app): + app.teardown_appcontext(close_db) + +if __name__ == "__main__": + db_path = path.join('/home/flask/server/instance/', default_database_name) + if path.exists(db_path): + print('Database already exists at {}. Will NOT override, if necessary first delete first then restart initialization!'.format(db_path)) + exit(1) + schema_path = path.join(path.dirname(__file__), 'schema.sql') + init_db(db_path = db_path, schema_path = schema_path) + print('done') diff --git a/server/application/backend/data/schema.sql b/server/application/backend/data/schema.sql new file mode 100644 index 0000000..3e58fd6 --- /dev/null +++ b/server/application/backend/data/schema.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS user; + +CREATE TABLE device ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_token TEXT NOT NULL, + encryption_key TEXT NOT NULL, + device_name TEXT NOT NULL +); \ No newline at end of file diff --git a/server/application/backend/encrypt.py b/server/application/backend/encrypt.py new file mode 100644 index 0000000..fca95b2 --- /dev/null +++ b/server/application/backend/encrypt.py @@ -0,0 +1,24 @@ +from Crypto.PublicKey import RSA +from Crypto.Random import get_random_bytes +from Crypto.Cipher import AES, PKCS1_OAEP +from Crypto.Util.Padding import pad +import base64 + +def encrypt(message: str, encryption_key: str): + base64_decoded_key = base64.b64decode(encryption_key) + recipient_key = RSA.import_key(base64_decoded_key) + cipher_rsa = PKCS1_OAEP.new(recipient_key) + session_key = get_random_bytes(16) + enc_session_key = cipher_rsa.encrypt(session_key) + iv_key = get_random_bytes(16) + enc_iv_key = cipher_rsa.encrypt(iv_key) + cipher_aes = AES.new(session_key, AES.MODE_CBC, iv_key) + ciphertext = cipher_aes.encrypt(pad(message.encode("utf-8"), AES.block_size)) + return { + "session_key": _encodeBytes(enc_session_key), + "iv": _encodeBytes(enc_iv_key), + "message": _encodeBytes(ciphertext), + } + +def _encodeBytes(bytes: bytes) : + return base64.encodebytes(bytes).decode('ASCII').replace('\n','') \ No newline at end of file diff --git a/server/application/backend/flask_project.py b/server/application/backend/flask_project.py new file mode 100644 index 0000000..8127bf3 --- /dev/null +++ b/server/application/backend/flask_project.py @@ -0,0 +1,109 @@ +from os import path +from flask import render_template +from .data import db as db +from .data.data_models import ResponseCode +from .data.data_models import DataError +from .data.data_models import Device +from .data import dao_device +from .encrypt import encrypt + +import firebase_admin +from firebase_admin import credentials, messaging + +import json +from flask import request, jsonify, redirect +from flask import Flask + +def create_app(test_config=None): + app = Flask(__name__) + if (test_config == None): + app.config.from_file('config.json', silent=True, load=json.load) + else: + app.config.from_mapping(test_config) + db.init_app(app) + + firebase_cred = credentials.Certificate(app.config.get('FIREBASE_JSON')) + firebase_app = firebase_admin.initialize_app(firebase_cred) + + @app.route('/', methods=['GET']) + def home(): + devices = dao_device.get_devices() + token_limit = app.config.get('TOKEN_SHOW_LIMIT') + if (token_limit is None): + token_limit = 2 + device_name_and_token = map(lambda device: format_device(device=device, token_limit=token_limit), devices) + + return render_template('index.html', devices=device_name_and_token) + + def format_device(device: Device, token_limit: int): + device_token_first = device.token[:token_limit] + device_token_last = device.token[-token_limit:] + device_token = device_token_first + ' ... ' + device_token_last + return (device.name, device_token) + + @app.route('/delete', methods=['POST']) + def registerDelete(): + device_name = request.form.get('device_name') + if device_name is None: + errorResponse = jsonify({'message':'DeviceName cannot be empty!','code':ResponseCode.EMPTY_DEVICE_NAME}) + return errorResponse, 400 + dao_device.delete_device_by_name(name=device_name) + return redirect("/") + + @app.route("/register", methods=['POST']) + def register(): + device_token = request.form.get('device_token') + device_name = request.form.get('device_name') + encryption_key = request.form.get('encryption_key') + if device_token is None: + errorResponse = jsonify({'message':'DeviceToken cannot be empty!','code':ResponseCode.EMPTY_DEVICE_TOKEN, 'request': request.form}) + return errorResponse, 400 + if device_name is None: + errorResponse = jsonify({'message':'DeviceName cannot be empty!','code':ResponseCode.EMPTY_DEVICE_NAME}) + return errorResponse, 400 + if encryption_key is None: + errorResponse = jsonify({'message':'DeviceEncryption cannot be empty!','code':ResponseCode.EMPTY_DEVICE_ENCRYPTION}) + return errorResponse, 400 + + device = Device(name=device_name, token=device_token, encryption_key=encryption_key) + dao_device.delete_device_by_name(name=device.name) + result = dao_device.insert_device(device) + if result == DataError.DEVICE_INSERT_ERROR: + errorResponse = jsonify({'message':'Couldn\'t save device!','code':ResponseCode.DEVICE_SAVE_FAILURE}) + return errorResponse, 400 + + return redirect("/") + + @app.route("/notify", methods=['POST']) + def notify(): + service = request.form.get('service') # name of the service + priority = request.form.get('priority') # Low, Medium, High + log = request.form.get('log') # log message + + # could use batching but there shouldn't be that many devices so ¯\_(ツ)_/¯ + devices = dao_device.get_devices() + if service and priority and log: + for device in devices: + dataWithEncryptedLog = encrypt(message=log, encryption_key=device.encryption_key) + dataWithEncryptedLog['priority'] = priority + dataWithEncryptedLog['service'] = service + message = messaging.Message( + data = dataWithEncryptedLog, + token = device.token + ) + messaging.send(message) + return redirect("/") + else: + errorResponse = jsonify({'message':'service, priority & log cannot be empty!','code':ResponseCode.NOTIFICATION_PARAMS_MISSING}) + return errorResponse, 400 + + return app + +if __name__ == "__main__": + app = create_app() + app.run(host='0.0.0.0') + + + + + \ No newline at end of file diff --git a/server/application/backend/static/android.js b/server/application/backend/static/android.js new file mode 100644 index 0000000..c2ecd73 --- /dev/null +++ b/server/application/backend/static/android.js @@ -0,0 +1,9 @@ +const deviceNameInput = document.getElementById("add_device_name"); +const deviceTokenInput = document.getElementById("add_device_token"); +const encryptionKeyInput = document.getElementById("add_encryption_key"); + +if (typeof Android !== 'undefined') { + encryptionKeyInput.value = Android.publicKey() + deviceTokenInput.value = Android.messagingToken() + deviceNameInput.value = Android.deviceName() +} \ No newline at end of file diff --git a/server/application/backend/static/index.js b/server/application/backend/static/index.js new file mode 100644 index 0000000..f7b6504 --- /dev/null +++ b/server/application/backend/static/index.js @@ -0,0 +1,16 @@ +const addDeviceDialog = document.getElementById("add_device_dialog"); +const openAddDeviceCTA = document.getElementById("add_device"); + +// close dialog on backdrop click +addDeviceDialog.addEventListener('click', function(event) { + var rect = addDeviceDialog.getBoundingClientRect(); + var isInDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && event.clientX <= rect.left + rect.width); + if (!isInDialog) { + addDeviceDialog.close(); + } +}); + +openAddDeviceCTA.addEventListener("click", () => { + addDeviceDialog.showModal(); +}); \ No newline at end of file diff --git a/server/application/backend/static/style.css b/server/application/backend/static/style.css new file mode 100644 index 0000000..0c49c5d --- /dev/null +++ b/server/application/backend/static/style.css @@ -0,0 +1,94 @@ +body { + background-color: #111; + color: #ddd; + align-items: center; +} +dialog { + background-color: #111; + border-color: #222; + color: #ddd; + align-items: center; +} + +dialog { + padding: 16px 24px; +} + +#container { + display: flex; + flex-direction: column; + align-items: center; +} + +span { + cursor: pointer; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; /* Standard syntax */ +} + +#devices { + font-family: Arial, Helvetica, sans-serif; + border-collapse: collapse; +} +#devices td, #devices th { + border: 1px solid #222; + padding: 8px; +} +#devices tr { + color: #ddd; +} +td.delete { + text-align: center; + cursor: pointer; +} +#devices tr:nth-child(even){background-color: #121212;} +#devices tr:nth-child(odd){background-color: #060606;} +#devices tr:hover {background-color: #202020;} + +#devices th { + padding-top: 12px; + padding-bottom: 12px; + text-align: left; + background-color: #310D78; + color: white; +} + +::backdrop { + background-color: #000000; + opacity: 0.9; +} + +.icon_button { + background-color: #00000000; + border: none; + color: white; + padding: 12px 12px; + cursor: pointer; +} + +label { + font-size: 0.8rem; + padding: 0px 0px 4px 2px; +} + +input[type=text], input[type=password] { + background-color: #101010; + color: #DDD; + border: 2px solid #222; + outline: none; +} + +input[type=text]:focus, input[type=password]:focus { + background-color: #060606; +} + +input[type=submit].primary { + background-color: #310D78; + color: #FFF; + padding: 4px 8px; + outline: none; + border: 0; + box-shadow: none; + border-radius: 0px; +} \ No newline at end of file diff --git a/server/application/backend/templates/index.html b/server/application/backend/templates/index.html new file mode 100644 index 0000000..9def1bc --- /dev/null +++ b/server/application/backend/templates/index.html @@ -0,0 +1,58 @@ + + + + + + Registered devices + + + + + + +
+

Registered devices

+ + + + + + +{% for device in devices %} + + + + + +{% endfor %} +
Device NameTokenAction
{{ device[0] }}{{ device[1] }} +
+ + +
+
+
+

Send test notification + + + + +

+
+
+ +
+
+

+ +
+

+ +
+

+ + +
+
+ + \ No newline at end of file diff --git a/server/application/flask-wsgi.py b/server/application/flask-wsgi.py new file mode 100644 index 0000000..5c0a3e3 --- /dev/null +++ b/server/application/flask-wsgi.py @@ -0,0 +1,5 @@ +from backend.flask_project import create_app + +app = create_app() +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/server/application/notification-service.ini b/server/application/notification-service.ini new file mode 100644 index 0000000..653770b --- /dev/null +++ b/server/application/notification-service.ini @@ -0,0 +1,15 @@ +[uwsgi] +module = flask-wsgi:app + +master = true +processes = 5 + +socket=:3000 +chmod-socket = 666 +protocol = http + +vacuum = true + +die-on-term = true + +enable-threads = true \ No newline at end of file diff --git a/server/create-db.sh b/server/create-db.sh new file mode 100755 index 0000000..b04d3e8 --- /dev/null +++ b/server/create-db.sh @@ -0,0 +1,8 @@ +IMAGE=pns + +docker build --tag $IMAGE . + +mkdir application/instance +docker run -v $PWD/application:/home/flask/server --rm -it $IMAGE python backend/data/db.py + +docker image rm $IMAGE \ No newline at end of file diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..a947d2e --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,11 @@ +services: + notification_service: + build: + context: . + dockerfile: ./Dockerfile + restart: "unless-stopped" + ports: + - 127.0.0.1:8080:3000 + #- 8080:80 + volumes: + - ./application:/home/flask/server