├── .docs ├── features.png └── sam_beckman_review_thumbnail.jpg ├── app ├── src │ ├── main │ │ ├── res │ │ │ ├── values-sw360dp │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── values-v31 │ │ │ │ └── themes.xml │ │ │ ├── drawable │ │ │ │ ├── switch_thumb_icon.xml │ │ │ │ ├── ic_info.xml │ │ │ │ ├── ic_feedback.xml │ │ │ │ ├── ic_chevron_right.xml │ │ │ │ ├── ic_copy.xml │ │ │ │ ├── ic_save.xml │ │ │ │ ├── ic_switch_check.xml │ │ │ │ ├── ic_external_link.xml │ │ │ │ ├── ic_play_store.xml │ │ │ │ ├── ic_linkedin.xml │ │ │ │ ├── ic_send.xml │ │ │ │ ├── ic_heart.xml │ │ │ │ ├── ic_telegram.xml │ │ │ │ ├── ic_notification_service.xml │ │ │ │ ├── logo.xml │ │ │ │ ├── ic_copyright.xml │ │ │ │ ├── ic_twitter.xml │ │ │ │ ├── ic_visibility_off.xml │ │ │ │ ├── ic_github.xml │ │ │ │ ├── ic_phone.xml │ │ │ │ ├── ic_switch_switch.xml │ │ │ │ ├── ic_qr_code.xml │ │ │ │ ├── ic_handshake.xml │ │ │ │ └── ic_launcher_monochrome.xml │ │ │ ├── values-night │ │ │ │ ├── styles.xml │ │ │ │ └── themes.xml │ │ │ ├── xml │ │ │ │ ├── backup_rules_11_down.xml │ │ │ │ └── backup_rules_12_up.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ └── ic_launcher.xml │ │ │ ├── menu │ │ │ │ └── toolbar.xml │ │ │ ├── values │ │ │ │ ├── dimens.xml │ │ │ │ ├── themes.xml │ │ │ │ ├── styles.xml │ │ │ │ ├── colors.xml │ │ │ │ └── strings.xml │ │ │ ├── layout │ │ │ │ ├── tab_tutorial.xml │ │ │ │ ├── sheet_optimization_remover.xml │ │ │ │ ├── dialog_pair.xml │ │ │ │ └── tab_permission.xml │ │ │ └── values-ar │ │ │ │ └── strings.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── anissan │ │ │ │ └── battarang │ │ │ │ ├── ui │ │ │ │ ├── views │ │ │ │ │ ├── notifierServiceToggle.kt │ │ │ │ │ ├── optimization │ │ │ │ │ │ ├── OptimizationRequestDialog.kt │ │ │ │ │ │ ├── tabs │ │ │ │ │ │ │ ├── TutorialTabViewHolder.kt │ │ │ │ │ │ │ ├── TabAdapter.kt │ │ │ │ │ │ │ └── PermissionTabViewHolder.kt │ │ │ │ │ │ └── OptimizationRemoverSheet.kt │ │ │ │ │ ├── appBar.kt │ │ │ │ │ ├── utils.kt │ │ │ │ │ ├── deviceNameInput.kt │ │ │ │ │ ├── lowLevelCheckbox.kt │ │ │ │ │ ├── skipWhileDisplayOnCheckbox.kt │ │ │ │ │ ├── pairing │ │ │ │ │ │ ├── qrScanner.kt │ │ │ │ │ │ └── pairingDialog.kt │ │ │ │ │ ├── pairReceiverFab.kt │ │ │ │ │ ├── buttonBar.kt │ │ │ │ │ ├── permission │ │ │ │ │ │ └── NotificationPermissionRequest.kt │ │ │ │ │ ├── maxLevelCheckbox.kt │ │ │ │ │ └── about │ │ │ │ │ │ └── AboutSheet.kt │ │ │ │ └── MainActivity.kt │ │ │ │ ├── background │ │ │ │ ├── receivers │ │ │ │ │ ├── BootEventReceiver.kt │ │ │ │ │ ├── ChargerConnectionReceiver.kt │ │ │ │ │ ├── ChargerConnectedReceiver.kt │ │ │ │ │ ├── BatteryLevelCheckerAlarmReceiver.kt │ │ │ │ │ ├── BatteryLowReceiver.kt │ │ │ │ │ └── handlers │ │ │ │ │ │ └── BroadcastedEventHandlers.kt │ │ │ │ └── services │ │ │ │ │ └── BroadcastReceiverRegistererService.kt │ │ │ │ ├── MainApplication.kt │ │ │ │ ├── data │ │ │ │ └── LocalKvStore.kt │ │ │ │ ├── network │ │ │ │ └── ReceiverApiClient.kt │ │ │ │ └── utils │ │ │ │ ├── DynamicColorExtract.kt │ │ │ │ └── Ulog.kt │ │ └── AndroidManifest.xml │ └── debug │ │ └── res │ │ └── values │ │ └── strings.xml ├── proguard-rules.pro └── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml └── dictionaries │ └── ni554n.xml ├── .gitignore ├── settings.gradle ├── local.example.properties ├── gradle.properties ├── gradlew.bat ├── README.md └── gradlew /.docs/features.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/.docs/features.png -------------------------------------------------------------------------------- /app/src/main/res/values-sw360dp/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Nissan Ahmed 3 | 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Battabug 3 | 4 | -------------------------------------------------------------------------------- /.docs/sam_beckman_review_thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/.docs/sam_beckman_review_thumbnail.jpg -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ni554n/battarang-notifier-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | *.iml 3 | .idea/ 4 | !.idea/dictionaries/ 5 | !.idea/codeStyles/ 6 | !.idea/inspectionProfiles/ 7 | .kotlin/ 8 | 9 | # Gradle files 10 | .gradle/ 11 | build/ 12 | release/ 13 | 14 | # Local configuration file (sdk path, secrets, etc) 15 | local.properties 16 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/switch_thumb_icon.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules_11_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/menu/toolbar.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 464dp 4 | 16dp 5 | 6 | 8 | 260dp 9 | 10 | -------------------------------------------------------------------------------- /.idea/dictionaries/ni554n.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | anissan 5 | battabug 6 | battarang 7 | bottomsheet 8 | broadcasted 9 | doki 10 | overline 11 | poweron 12 | quickboot 13 | sharedpref 14 | snackbar 15 | ulog 16 | 17 | 18 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | // https://developer.android.com/studio/build 2 | pluginManagement { 3 | repositories { 4 | google() 5 | mavenCentral() 6 | gradlePluginPortal() 7 | } 8 | } 9 | 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | 17 | maven { url = 'https://jitpack.io' } 18 | } 19 | } 20 | 21 | rootProject.name = 'Battarang Notifier' 22 | include ':app' 23 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules_12_up.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_info.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_feedback.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_chevron_right.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_copy.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_save.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_switch_check.xml: -------------------------------------------------------------------------------- 1 | 7 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 6 | 7 | 12 | 13 | 20 | 21 | 25 | 26 | 30 | 31 | 34 | 35 | 39 | 40 | 44 | 45 | 51 | 52 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/buttonBar.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views 2 | 3 | import android.graphics.drawable.Drawable 4 | import android.view.View 5 | import androidx.appcompat.widget.TooltipCompat 6 | import com.anissan.battarang.R 7 | import com.anissan.battarang.network.MessageType 8 | import com.anissan.battarang.ui.MainActivity 9 | import com.anissan.battarang.ui.views.about.AboutSheet 10 | import com.google.android.material.progressindicator.CircularProgressIndicatorSpec 11 | import com.google.android.material.progressindicator.IndeterminateDrawable 12 | import com.google.android.material.snackbar.Snackbar 13 | 14 | fun MainActivity.setupButtonBar() { 15 | binding.buttonBarCard.setCardBackgroundColor(dynamicSurfaceColor) 16 | 17 | unpairButton() 18 | testButton() 19 | aboutButton() 20 | } 21 | 22 | fun MainActivity.unpairButton() { 23 | binding.unpairButton.apply { 24 | visibility = if (paired) View.VISIBLE else View.GONE 25 | 26 | setOnClickListener { 27 | showSnackbar(R.string.unpair_instruction, Snackbar.LENGTH_SHORT) 28 | } 29 | 30 | setOnLongClickListener { 31 | localKvStore.receiverToken = null 32 | true 33 | } 34 | } 35 | } 36 | 37 | fun MainActivity.testButton() { 38 | val sendIcon: Drawable = binding.testButton.icon 39 | 40 | val loadingIcon = IndeterminateDrawable.createCircularDrawable( 41 | this, 42 | CircularProgressIndicatorSpec( 43 | this, 44 | null, 45 | 0, 46 | com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall, 47 | ) 48 | ) 49 | 50 | binding.testButton.apply { 51 | visibility = if (paired) View.VISIBLE else View.GONE 52 | 53 | TooltipCompat.setTooltipText(this, contentDescription) 54 | 55 | setOnClickListener { 56 | icon = loadingIcon 57 | 58 | receiverApiClient.sendNotification(MessageType.TEST) { response: String? -> 59 | icon = sendIcon 60 | 61 | if (response == null) showSnackbar(R.string.network_error) 62 | else { 63 | val message = response.split("||") 64 | 65 | showSnackbar( 66 | if (message.size == 3) 67 | message.drop(1).joinToString(". ") { it.trim() } 68 | else response) 69 | } 70 | } 71 | } 72 | } 73 | 74 | binding.serviceNameText.text = localKvStore.pairedServiceName 75 | } 76 | 77 | fun MainActivity.aboutButton() { 78 | binding.aboutButton.setOnClickListener { 79 | AboutSheet.show(supportFragmentManager) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_qr_code.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 13 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 49 | 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/permission/NotificationPermissionRequest.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views.permission 2 | 3 | import android.Manifest 4 | import android.content.Intent 5 | import android.os.Build 6 | import android.provider.Settings 7 | import androidx.activity.result.ActivityResultLauncher 8 | import androidx.activity.result.contract.ActivityResultContracts 9 | import androidx.annotation.RequiresApi 10 | import androidx.core.app.ActivityCompat 11 | import com.anissan.battarang.R 12 | import com.anissan.battarang.ui.MainActivity 13 | import com.anissan.battarang.ui.views.pairing.showPairingDialog 14 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 15 | 16 | lateinit var requestPermissionLauncher: ActivityResultLauncher 17 | 18 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 19 | fun MainActivity.registerForRequestingPermission(): ActivityResultLauncher { 20 | requestPermissionLauncher = registerForActivityResult( 21 | ActivityResultContracts.RequestPermission() 22 | ) { isGranted: Boolean -> 23 | if (isGranted) { 24 | showPairingDialog() 25 | } else { 26 | requestNotificationPermission(requestPermissionLauncher, true) 27 | } 28 | } 29 | 30 | return requestPermissionLauncher 31 | } 32 | 33 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 34 | fun MainActivity.requestNotificationPermission( 35 | requestPermissionLauncher: ActivityResultLauncher, 36 | showSettingsAction: Boolean = false, 37 | ) { 38 | when { 39 | ActivityCompat.shouldShowRequestPermissionRationale( 40 | this, Manifest.permission.POST_NOTIFICATIONS 41 | ) -> { 42 | MaterialAlertDialogBuilder(this) 43 | .setCancelable(false) 44 | .setTitle(R.string.notification_permission_rational_dialog_title) 45 | .setMessage(R.string.notification_permission_rational_dialog_description) 46 | .setPositiveButton("Allow") { _, _ -> 47 | requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 48 | }.show() 49 | } 50 | 51 | showSettingsAction -> { 52 | MaterialAlertDialogBuilder(this) 53 | .setCancelable(false) 54 | .setTitle(R.string.notification_permission_rational_dialog_title) 55 | .setMessage(R.string.notification_permission_rational_dialog_description) 56 | .setPositiveButton("Settings") { _, _ -> 57 | startActivity(Intent().apply { 58 | action = Settings.ACTION_APP_NOTIFICATION_SETTINGS 59 | putExtra(Settings.EXTRA_APP_PACKAGE, packageName) 60 | }) 61 | }.show() 62 | } 63 | 64 | else -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/res/layout/dialog_pair.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 17 | 18 | 36 | 37 | 55 | 56 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_handshake.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 16 | 24 | 32 | 40 | 48 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 65 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'com.mikepenz.aboutlibraries.plugin' 5 | } 6 | 7 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 8 | 9 | android { 10 | namespace = 'com.anissan.battarang' 11 | buildToolsVersion = '36.0.0' 12 | compileSdk = 36 13 | 14 | defaultConfig { 15 | targetSdk = 36 16 | minSdk = 21 17 | versionCode = 7 // Set a reminder to update the backend version after full rollout 18 | versionName = '1.8.2' 19 | 20 | // Generate "BuildConfig.*" properties 21 | Properties buildConfigProperties = new Properties() 22 | buildConfigProperties.load(new FileInputStream(rootProject.file('local.properties'))) 23 | 24 | // Skip auto generated Gradle value 25 | buildConfigProperties.remove('sdk.dir') 26 | 27 | // Values loaded as characters; adding quotes to make them strings 28 | buildConfigProperties.each { key, value -> { buildConfigField('String', key, "\"$value\"") } } 29 | } 30 | 31 | buildTypes { 32 | debug { 33 | applicationIdSuffix = '.debug' 34 | versionNameSuffix = '-debug' 35 | } 36 | 37 | release { 38 | minifyEnabled = true 39 | shrinkResources = true 40 | proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro') 41 | 42 | ndk { 43 | debugSymbolLevel = 'FULL' 44 | } 45 | } 46 | } 47 | 48 | compileOptions { 49 | sourceCompatibility = JavaVersion.VERSION_21 50 | targetCompatibility = JavaVersion.VERSION_21 51 | } 52 | 53 | kotlin { 54 | jvmToolchain(21) 55 | compilerOptions { 56 | jvmTarget = JvmTarget.JVM_21 57 | } 58 | } 59 | 60 | buildFeatures { 61 | buildConfig = true 62 | viewBinding = true 63 | } 64 | } 65 | 66 | dependencies { 67 | implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.2.20' 68 | implementation 'org.jetbrains:annotations:26.0.2-1' 69 | 70 | implementation 'androidx.appcompat:appcompat:1.7.1' 71 | implementation 'androidx.core:core-ktx:1.17.0' 72 | implementation 'androidx.fragment:fragment-ktx:1.8.9' 73 | implementation 'androidx.constraintlayout:constraintlayout:2.2.1' 74 | implementation 'androidx.viewpager2:viewpager2:1.1.0' 75 | //noinspection GradleDependency Higher versions include breaking changes that require re-theming 76 | implementation 'com.google.android.material:material:1.12.0' 77 | 78 | // `com.google.common.util.concurrent.ListenableFuture` imported in PermissionTabViewHolder 79 | implementation 'com.google.guava:guava:33.4.8-android' 80 | 81 | implementation 'io.insert-koin:koin-android:4.1.1' 82 | implementation 'com.squareup.okhttp3:okhttp:5.1.0' 83 | implementation 'io.github.g00fy2.quickie:quickie-bundled:1.11.0' 84 | implementation 'dev.chrisbanes.insetter:insetter:0.6.1' 85 | implementation 'com.github.judemanutd:autostarter:1.1.0' 86 | implementation 'com.mikepenz:aboutlibraries:12.2.4' 87 | implementation 'hu.autsoft:krate:2.0.0' 88 | 89 | // This snapshot version can set the app name for Doki API to use in instructions 90 | implementation 'dev.doubledot.doki:library:c18efeb' 91 | } 92 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #9DD7B1 7 | 8 | 9 | #92D5AB 10 | #003920 11 | #085231 12 | #AEF2C6 13 | #AEF2C6 14 | #002111 15 | #92D5AB 16 | #085231 17 | #296A48 18 | #B5CCBA 19 | #213528 20 | #374B3E 21 | #D1E8D6 22 | #D1E8D6 23 | #0C1F14 24 | #B5CCBA 25 | #374B3E 26 | #A3CDDC 27 | #033541 28 | #224C58 29 | #BFE9F8 30 | #BFE9F8 31 | #001F27 32 | #A3CDDC 33 | #224C58 34 | #F2B8B5 35 | #8C1D18 36 | #601410 37 | #F9DEDC 38 | #8A938B 39 | #404942 40 | #191C1A 41 | #E1E3DE 42 | #071919 43 | #E1E3DE 44 | #111412 45 | #373A37 46 | #404942 47 | #C0C9C0 48 | #E1E3DE 49 | #2E312E 50 | #1D201E 51 | #071919 52 | #0C0F0D 53 | #282B28 54 | #323533 55 | 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Battarang Notifier for Android

2 | 3 |
4 | GitHub Downloads 5 | GitHub Downloads 6 |
7 | 8 |
9 | 10 |

🔔🔋 Sync battery notifications across devices 🪫🔔

11 | 12 | ![Battarang Features](.docs/features.png) 13 | 14 | ## Setup 15 | 16 | 1. **Download** the latest APK from [GitHub Releases](https://github.com/ni554n/battarang-notifier-android/releases) or consider purchasing it from the [Play Store](https://play.google.com/store/apps/details?id=com.anissan.battarang) for automatic updates and to support development. 17 | 2. **Install** the app on your sender device (or multiple devices). 18 | 3. To **Pair it with a receiver** device or Telegram, visit [battarang.anissan.com](https://battarang.anissan.com) from another device and follow the instructions. 19 | 20 | > [!TIP] 21 | > 22 | > Sam Beckman created an awesome review and setup tutorial for Battarang on [YouTube](https://www.youtube.com/watch?v=xthkvsnNb-8&t=237s) 23 | > 24 | > 25 | 26 | ## Architecture 27 | 28 | The architecture of this project may seem unfamiliar to a seasoned Android developer 29 | because I came up with an architecture that is tailored to the features of this project. 30 | I took a web dev approach rather than over-engineering a Google scale solution where it feels 31 | counter-productive. 32 | 33 | Essentially the source of truth is SharedPref KV storage and, the Views get updated by observing the 34 | changes. 35 | It's kind of like a poor man's reactive system. 36 | 37 | I've also heavily used Kotlin Extension Functions rather than Classes to help with the composition 38 | to minimize the changes to a few places as possible. 39 | 40 | ## Build 41 | 42 | 1. Copy the properties from [local.example.properties](local.example.properties) 43 | to `local.properties` and provide the values 44 | 2. `🔨 Make Project` or `▶️ Run` the app 45 | 46 | ## Information 47 | 48 | **Author:** [Nissan Ahmed](https://anissan.com) ([@ni554n](https://twitter.com/ni554n)) 49 | 50 | **Donate:** [PayPal](https://paypal.me/ni554n) 51 | 52 | 53 | ## License 54 | 55 | This project intentionally 56 | has [no license](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository#choosing-the-right-license) 57 | so the default copyright laws apply, 58 | which means I retain all rights to the source code and the graphical assets. 59 | No one may reproduce, distribute, or create derivative works from this work without my permission. 60 | 61 | However you are free to view, contribute, and copy parts of the code into your own project without 62 | attribution. 63 | You are just not allowed to repackage and redistribute the entire app. 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/data/LocalKvStore.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.data 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import com.anissan.battarang.network.SupportedService 6 | import com.anissan.battarang.ui.views.defaultDeviceName 7 | import com.anissan.battarang.utils.logV 8 | import hu.autsoft.krate.SimpleKrate 9 | import hu.autsoft.krate.booleanPref 10 | import hu.autsoft.krate.default.withDefault 11 | import hu.autsoft.krate.intPref 12 | import hu.autsoft.krate.stringPref 13 | 14 | /** 15 | * ⚠️ If not careful, renaming these can become a breaking change and stored values will be lost. 16 | */ 17 | enum class PrefKey { 18 | DEVICE_NAME, 19 | NOTIFICATION_SERVICE_TOGGLE, 20 | MAX_LEVEL_NOTIFICATION_TOGGLE, 21 | MAX_LEVEL_PERCENTAGE, 22 | LOW_BATTERY_NOTIFICATION_TOGGLE, 23 | SKIP_WHILE_SCREEN_ON_TOGGLE, 24 | PAIRED_SERVICE_TAG, 25 | RECEIVER_TOKEN, 26 | LAST_MESSAGE_ID, 27 | } 28 | 29 | /** 30 | * Local KV store for saving app states and user preferences into the default SharedPreferences. 31 | */ 32 | class LocalKvStore(context: Context) : SimpleKrate(context), 33 | SharedPreferences.OnSharedPreferenceChangeListener { 34 | 35 | var deviceName: String 36 | by stringPref(PrefKey.DEVICE_NAME.name).withDefault(context.defaultDeviceName) 37 | 38 | var isMonitoringServiceEnabled: Boolean 39 | by booleanPref(PrefKey.NOTIFICATION_SERVICE_TOGGLE.name).withDefault(true) 40 | 41 | var isMaxLevelNotificationEnabled: Boolean 42 | by booleanPref(PrefKey.MAX_LEVEL_NOTIFICATION_TOGGLE.name).withDefault(true) 43 | 44 | var maxChargingLevelPercentage: Int 45 | by intPref(PrefKey.MAX_LEVEL_PERCENTAGE.name).withDefault(90) 46 | 47 | var isLowBatteryNotificationEnabled: Boolean 48 | by booleanPref(PrefKey.LOW_BATTERY_NOTIFICATION_TOGGLE.name).withDefault(true) 49 | 50 | var isSkipWhileDisplayOnEnabled: Boolean 51 | by booleanPref(PrefKey.SKIP_WHILE_SCREEN_ON_TOGGLE.name).withDefault(true) 52 | 53 | /** 54 | * It's going to be either the FCM token generated on the receiver device 55 | * or the current Chat ID from the Telegram Bot. 56 | * */ 57 | var receiverToken: String? by stringPref(PrefKey.RECEIVER_TOKEN.name) 58 | 59 | /** This ID is going to be used to delete the last sent message to keep the message history at minimum. */ 60 | var lastTelegramMessageId: String? by stringPref(PrefKey.LAST_MESSAGE_ID.name) 61 | 62 | /** Must be one of the [SupportedService.FCM] or [SupportedService.TG]. */ 63 | var pairedServiceTag: String? by stringPref(PrefKey.PAIRED_SERVICE_TAG.name) 64 | 65 | val pairedServiceName: String 66 | get() { 67 | return if (receiverToken == null) "" else SupportedService.valueOf(pairedServiceTag!!).serviceName 68 | } 69 | 70 | //endregion 71 | 72 | private lateinit var _changeListener: (key: String) -> Unit 73 | 74 | fun startObservingChanges(changeListener: (key: String) -> Unit) { 75 | _changeListener = changeListener 76 | 77 | sharedPreferences.registerOnSharedPreferenceChangeListener(this) 78 | logV { "Started observing sharedPreferences changes…" } 79 | } 80 | 81 | fun stopObservingChanges() { 82 | sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) 83 | logV { "Stopped observing for sharedPreferences changes." } 84 | } 85 | 86 | override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { 87 | logV { "$key has been updated by user" } 88 | if (key == null) return 89 | 90 | _changeListener(key) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 20 | 24 | 31 | 38 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/maxLevelCheckbox.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views 2 | 3 | import android.graphics.Typeface 4 | import android.text.Spannable 5 | import android.text.SpannableStringBuilder 6 | import android.text.style.ForegroundColorSpan 7 | import android.text.style.StyleSpan 8 | import android.widget.CompoundButton 9 | import com.anissan.battarang.R 10 | import com.anissan.battarang.ui.MainActivity 11 | import com.google.android.material.color.MaterialColors 12 | import com.google.android.material.slider.Slider 13 | 14 | fun MainActivity.setupMaxBatteryLevelCheckbox() { 15 | binding.configOptionsCard.setCardBackgroundColor(dynamicSurfaceColor) 16 | 17 | binding.maxBatteryLevelCheckbox.run { 18 | isChecked = localKvStore.isMaxLevelNotificationEnabled 19 | 20 | /** 21 | * Setting up a bold and text color Span on the "~%" portion of the checkbox text, so that, 22 | * during the slider value update any number inserted in the middle is going to retain the formatting 23 | */ 24 | 25 | val maxLevelSpannableStringBuilder = 26 | SpannableStringBuilder(getString(R.string.battery_level_reaches_template)) 27 | 28 | // These span positions are like text input cursors => | 29 | // Insert at 1 means inserting before b => "a|bc", at 2 means inserting after b => "ab|c" 30 | val percentEnd = maxLevelSpannableStringBuilder.length // ... ~%| 31 | val tildeStart = percentEnd - 2 // ... |~% 32 | 33 | val textColor = 34 | MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant) 35 | 36 | maxLevelSpannableStringBuilder.setSpan( 37 | StyleSpan(Typeface.BOLD), 38 | tildeStart, 39 | percentEnd, 40 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 41 | ) 42 | 43 | maxLevelSpannableStringBuilder.setSpan( 44 | ForegroundColorSpan(textColor), 45 | tildeStart, 46 | percentEnd, 47 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, 48 | ) 49 | 50 | val tildeEnd = tildeStart + 1 // ~|% 51 | 52 | binding.maxBatteryLevelCheckbox.text = maxLevelSpannableStringBuilder.insert( 53 | tildeEnd, 54 | localKvStore.maxChargingLevelPercentage.toString(), 55 | ) 56 | 57 | bindMaxLevelSlider(maxLevelSpannableStringBuilder, tildeEnd) 58 | 59 | bindClicksFrom(binding.maxBatteryLevelCard) 60 | 61 | setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean -> 62 | localKvStore.isMaxLevelNotificationEnabled = isChecked 63 | } 64 | } 65 | } 66 | 67 | private fun MainActivity.bindMaxLevelSlider( 68 | maxLevelSpannableStringBuilder: SpannableStringBuilder, 69 | tildeEnd: Int, 70 | ) { 71 | binding.maxBatteryLevelSlider.run { 72 | val savedMaxLevel: Int = localKvStore.maxChargingLevelPercentage 73 | value = savedMaxLevel.toFloat() 74 | 75 | // Keeping this length in the memory to determine how many digits it 76 | // should be replaced on a slider value update 77 | var currentMaxLevel: Int = "$savedMaxLevel".length 78 | 79 | addOnChangeListener { _: Slider, updatedValue: Float, _: Boolean -> 80 | val updatedLevelValue: Int = updatedValue.toInt() 81 | localKvStore.maxChargingLevelPercentage = updatedLevelValue 82 | 83 | val updatedLevelValueString = "$updatedLevelValue" 84 | 85 | binding.maxBatteryLevelCheckbox.text = maxLevelSpannableStringBuilder.replace( 86 | tildeEnd, // ~|85% 87 | tildeEnd + currentMaxLevel, // ~85|% 88 | updatedLevelValueString, 89 | ) 90 | 91 | currentMaxLevel = updatedLevelValueString.length 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/pairing/pairingDialog.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views.pairing 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.ClipboardManager 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AlertDialog 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.core.net.toUri 11 | import com.anissan.battarang.BuildConfig 12 | import com.anissan.battarang.R 13 | import com.anissan.battarang.databinding.DialogPairBinding 14 | import com.anissan.battarang.network.SupportedService 15 | import com.anissan.battarang.ui.MainActivity 16 | import com.anissan.battarang.utils.logE 17 | import com.anissan.battarang.utils.logV 18 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 19 | import com.google.android.material.snackbar.Snackbar 20 | import java.net.URI 21 | 22 | private lateinit var pairingDialog: AlertDialog 23 | 24 | fun MainActivity.showPairingDialog() { 25 | val dialogContentView = DialogPairBinding.inflate(layoutInflater) 26 | 27 | dialogContentView.receiverLinkButton.apply { 28 | text = URI(BuildConfig.RECEIVER_WEBSITE_SHORT_LINK).host 29 | 30 | setOnClickListener { 31 | val shareIntent = Intent().apply { 32 | action = Intent.ACTION_SEND 33 | type = "text/plain" 34 | putExtra(Intent.EXTRA_TEXT, BuildConfig.RECEIVER_WEBSITE_SHORT_LINK) 35 | } 36 | 37 | startActivity(Intent.createChooser(shareIntent, null)) 38 | } 39 | 40 | setOnLongClickListener { dialog -> dialog.performClick() } 41 | } 42 | 43 | dialogContentView.telegramLinkButton.setOnClickListener { 44 | try { 45 | startActivity(Intent(Intent.ACTION_VIEW, BuildConfig.TELEGRAM_BOT_URL.toUri())) 46 | } catch (_: ActivityNotFoundException) { 47 | Toast.makeText(this, R.string.telegram_app_not_found, Toast.LENGTH_LONG).show() 48 | } 49 | } 50 | 51 | pairingDialog = MaterialAlertDialogBuilder(this, R.style.PairingDialog) 52 | .setIcon(R.drawable.ic_external_link) 53 | .setTitle(R.string.pair_dialog_title) 54 | .setView(dialogContentView.root) 55 | .setNeutralButton(getString(R.string.pair_dialog_paste_button)) { _, _ -> 56 | val clipboard = (getSystemService(AppCompatActivity.CLIPBOARD_SERVICE)) as? ClipboardManager 57 | val clipboardText: CharSequence = 58 | clipboard?.primaryClip?.getItemAt(0)?.text ?: "" 59 | 60 | saveToken(clipboardText.toString()) 61 | } 62 | .setPositiveButton(getString(R.string.pair_dialog_scan_button)) { _, _ -> 63 | try { 64 | launchQrScanner(::saveToken) 65 | } catch (e: Exception) { 66 | logE(e) 67 | showSnackbar(R.string.camera_unavailable, Snackbar.LENGTH_SHORT) 68 | } 69 | } 70 | .show() 71 | } 72 | 73 | fun MainActivity.saveToken(providedText: String) { 74 | logV { "Scanned QR / pasted text: $providedText" } 75 | 76 | try { 77 | val (service, token) = providedText.split(":", limit = 2) 78 | localKvStore.pairedServiceTag = SupportedService.valueOf(service).name 79 | localKvStore.receiverToken = token.ifBlank { throw Exception("Token can not be blank.") } 80 | } catch (e: Exception) { 81 | logE(e) 82 | showSnackbar(R.string.invalid_token) 83 | return 84 | } 85 | } 86 | 87 | private const val PAIRING_DIALOG = "pairing_dialog" 88 | 89 | fun savePairingDialogState(outState: Bundle) { 90 | outState.putBoolean( 91 | PAIRING_DIALOG, 92 | if (::pairingDialog.isInitialized) pairingDialog.isShowing else false, 93 | ) 94 | } 95 | 96 | fun MainActivity.restorePairingDialogState(savedInstanceState: Bundle) { 97 | if (savedInstanceState.getBoolean(PAIRING_DIALOG)) showPairingDialog() 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/network/ReceiverApiClient.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.network 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import android.os.PowerManager 6 | import com.anissan.battarang.BuildConfig 7 | import com.anissan.battarang.data.LocalKvStore 8 | import com.anissan.battarang.utils.logE 9 | import com.anissan.battarang.utils.logI 10 | import com.anissan.battarang.utils.logV 11 | import okhttp3.Call 12 | import okhttp3.Callback 13 | import okhttp3.HttpUrl.Companion.toHttpUrl 14 | import okhttp3.OkHttpClient 15 | import okhttp3.Request 16 | import okhttp3.Response 17 | import okio.IOException 18 | import java.text.SimpleDateFormat 19 | import java.util.Date 20 | import java.util.Locale 21 | 22 | enum class MessageType { 23 | TEST, PAIRED, LOW, FULL 24 | } 25 | 26 | enum class SupportedService(val serviceName: String) { 27 | FCM("Receiver"), TG("Telegram") 28 | } 29 | 30 | class ReceiverApiClient( 31 | private val powerManager: PowerManager, 32 | private val okHttpClient: OkHttpClient, 33 | private val localKvStore: LocalKvStore, 34 | ) : Callback { 35 | private var _onResponseResult: ((String?) -> Unit)? = null 36 | 37 | fun sendNotification( 38 | messageType: MessageType, 39 | batteryLevel: Int? = null, 40 | onResponseResult: ((responseBody: String?) -> Unit)? = null, 41 | ) { 42 | _onResponseResult = onResponseResult 43 | 44 | val url: String = BuildConfig.RECEIVER_API_URL.toHttpUrl().newBuilder().apply { 45 | mapOf( 46 | "pairedService" to localKvStore.pairedServiceTag, 47 | "messageType" to messageType.name, 48 | "receiverToken" to localKvStore.receiverToken, 49 | "deviceName" to localKvStore.deviceName, 50 | "triggeredAt" to SimpleDateFormat("hh:mm a (EEEE)", Locale.getDefault()).format(Date()), 51 | "batteryLevel" to "${batteryLevel ?: "low"}", 52 | "lastTgMessageId" to localKvStore.lastTelegramMessageId, 53 | "androidAppVersionCode" to BuildConfig.VERSION_CODE.toString(), 54 | ).forEach { (parameter: String, value: String?) -> 55 | if (value.isNullOrBlank().not()) addQueryParameter(parameter, value) 56 | } 57 | }.build().toString() 58 | 59 | val wakeLock: PowerManager.WakeLock = powerManager.run { 60 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}::notify").apply { 61 | acquire(10 * 1000L) 62 | logI { "Secured a partial wakelock for up to 10 seconds." } 63 | } 64 | } 65 | 66 | try { 67 | okHttpClient.newCall(Request.Builder().url(url).get().build()) 68 | .enqueue(responseCallback = this) 69 | } finally { 70 | wakeLock.release() 71 | logI { "Wakelock released." } 72 | } 73 | } 74 | 75 | override fun onFailure(call: Call, e: IOException) { 76 | logE(e) 77 | 78 | if (_onResponseResult == null) return 79 | 80 | Handler(Looper.getMainLooper()).post { 81 | _onResponseResult?.invoke(null) 82 | } 83 | } 84 | 85 | override fun onResponse(call: Call, response: Response) { 86 | response.use { 87 | logV { "/api response: $response" } 88 | 89 | localKvStore.lastTelegramMessageId = response.headers["X-Tg-Message-Id"] 90 | 91 | if (_onResponseResult == null) return@use 92 | 93 | // Success or Error message should be sent from the server as a response body text. 94 | val bodyText = try { 95 | response.body.string() 96 | } catch (e: Exception) { 97 | logE(e) 98 | 99 | "Server sent an invalid response body." 100 | } 101 | 102 | Handler(Looper.getMainLooper()).post { 103 | _onResponseResult?.invoke(bodyText) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/about/AboutSheet.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package com.anissan.battarang.ui.views.about 4 | 5 | import android.content.ActivityNotFoundException 6 | import android.content.Intent 7 | import android.os.Bundle 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import android.widget.Toast 12 | import androidx.core.net.toUri 13 | import androidx.fragment.app.FragmentManager 14 | import com.anissan.battarang.BuildConfig 15 | import com.anissan.battarang.R 16 | import com.anissan.battarang.databinding.SheetAboutBinding 17 | import com.google.android.material.bottomsheet.BottomSheetDialog 18 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 19 | import com.google.android.material.elevation.SurfaceColors 20 | import com.mikepenz.aboutlibraries.LibsBuilder 21 | import dev.chrisbanes.insetter.applyInsetter 22 | import java.net.URI 23 | 24 | 25 | class AboutSheet : BottomSheetDialogFragment() { 26 | 27 | companion object { 28 | fun show(fragmentManager: FragmentManager) { 29 | AboutSheet().show(fragmentManager, "about_sheet") 30 | } 31 | } 32 | 33 | override fun onCreateView( 34 | inflater: LayoutInflater, 35 | container: ViewGroup?, 36 | savedInstanceState: Bundle?, 37 | ): View = SheetAboutBinding.inflate(inflater, container, false).run { 38 | // Bottom sheet's default auto peek height on widescreen is very low. 39 | // Setting it around 67% of the screen height. 40 | (dialog as BottomSheetDialog).behavior.peekHeight = 41 | (resources.displayMetrics.heightPixels / 1.5).toInt() 42 | 43 | aboutLayout.applyInsetter { 44 | type(navigationBars = true) { 45 | padding(vertical = true) 46 | } 47 | } 48 | 49 | setupViews() 50 | 51 | root 52 | } 53 | 54 | private fun SheetAboutBinding.setupViews() { 55 | authorDetailsCard.setCardBackgroundColor(SurfaceColors.SURFACE_3.getColor(requireContext())) 56 | 57 | receiverLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.RECEIVER_WEBSITE) } 58 | telegramLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.TELEGRAM_BOT_URL) } 59 | 60 | sourceLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.GITHUB_URL) } 61 | issuesLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.ISSUES_URL) } 62 | reviewLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.PLAY_STORE_LINK) } 63 | 64 | authorNameText.setOnClickListener { openLinkInBrowser(BuildConfig.AUTHOR_WEBSITE) } 65 | authorWebsiteLinkButton.text = URI(BuildConfig.AUTHOR_WEBSITE).host 66 | authorWebsiteLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.AUTHOR_WEBSITE) } 67 | twitterLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.AUTHOR_TWITTER) } 68 | linkedinLinkButton.setOnClickListener { openLinkInBrowser(BuildConfig.AUTHOR_LINKEDIN) } 69 | checkOutOtherProjectsButton.setOnClickListener { openLinkInBrowser(BuildConfig.AUTHOR_OTHER_PROJECTS) } 70 | 71 | licenseChip.setOnClickListener { 72 | LibsBuilder() 73 | .withActivityTitle(getString(R.string.licenses)) 74 | .withSearchEnabled(true) 75 | .start(requireContext()) 76 | } 77 | 78 | policyLinkChip.setOnClickListener { openLinkInBrowser(BuildConfig.PRIVACY_POLICY_URL) } 79 | tosLinkChip.setOnClickListener { openLinkInBrowser(BuildConfig.TOS_URL) } 80 | } 81 | 82 | private fun openLinkInBrowser(url: String) { 83 | try { 84 | startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) 85 | } catch (_: ActivityNotFoundException) { 86 | Toast.makeText(requireContext(), R.string.no_browser_found, Toast.LENGTH_LONG).show() 87 | } 88 | } 89 | 90 | override fun onStart() { 91 | super.onStart() 92 | 93 | // The sheet dialog is initially set to WRAP_CONTENT, which leaves a big gap at the bottom on Fullscreen mode. 94 | dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.layoutParams?.height = 95 | ViewGroup.LayoutParams.MATCH_PARENT 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/utils/DynamicColorExtract.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("Unused") 2 | 3 | package com.anissan.battarang.utils 4 | 5 | import android.content.Context 6 | import com.google.android.material.color.MaterialColors 7 | 8 | enum class Mode { LIGHT, DARK } 9 | 10 | /** 11 | * Prints the current dynamic colors generated by the system as XML Color Resources to Logcat. 12 | * It can be copied to `res/values/colors.xml` file and used 13 | * as the default theme color on the unsupported devices. 14 | * 15 | * Set the device theme to the desired color and light / dark mode from "Wallpaper & Style", 16 | * then call this function on MainActivity::onCreate with the current mode. 17 | */ 18 | fun Context.extractDynamicColorsFromDevice(mode: Mode) { 19 | logI { "Go to `Configure Logcat Formatting Options` and `Modify Views` to keep the messages only." } 20 | 21 | mapOf( 22 | "primary" to com.google.android.material.R.attr.colorPrimary, 23 | "onPrimary" to com.google.android.material.R.attr.colorOnPrimary, 24 | "primaryContainer" to com.google.android.material.R.attr.colorPrimaryContainer, 25 | "onPrimaryContainer" to com.google.android.material.R.attr.colorOnPrimaryContainer, 26 | "primaryFixed" to com.google.android.material.R.attr.colorPrimaryFixed, 27 | "onPrimaryFixed" to com.google.android.material.R.attr.colorOnPrimaryFixed, 28 | "primaryFixedDim" to com.google.android.material.R.attr.colorPrimaryFixedDim, 29 | "onPrimaryFixedVariant" to com.google.android.material.R.attr.colorOnPrimaryFixedVariant, 30 | "primaryInverse" to com.google.android.material.R.attr.colorPrimaryInverse, 31 | 32 | "secondary" to com.google.android.material.R.attr.colorSecondary, 33 | "onSecondary" to com.google.android.material.R.attr.colorOnSecondary, 34 | "secondaryContainer" to com.google.android.material.R.attr.colorSecondaryContainer, 35 | "onSecondaryContainer" to com.google.android.material.R.attr.colorOnSecondaryContainer, 36 | "secondaryFixed" to com.google.android.material.R.attr.colorSecondaryFixed, 37 | "onSecondaryFixed" to com.google.android.material.R.attr.colorOnSecondaryFixed, 38 | "secondaryFixedDim" to com.google.android.material.R.attr.colorSecondaryFixedDim, 39 | "onSecondaryFixedVariant" to com.google.android.material.R.attr.colorOnSecondaryFixedVariant, 40 | 41 | "tertiary" to com.google.android.material.R.attr.colorTertiary, 42 | "onTertiary" to com.google.android.material.R.attr.colorOnTertiary, 43 | "tertiaryContainer" to com.google.android.material.R.attr.colorTertiaryContainer, 44 | "onTertiaryContainer" to com.google.android.material.R.attr.colorOnTertiaryContainer, 45 | "tertiaryFixed" to com.google.android.material.R.attr.colorTertiaryFixed, 46 | "onTertiaryFixed" to com.google.android.material.R.attr.colorOnTertiaryFixed, 47 | "tertiaryFixedDim" to com.google.android.material.R.attr.colorTertiaryFixedDim, 48 | "onTertiaryFixedVariant" to com.google.android.material.R.attr.colorOnTertiaryFixedVariant, 49 | 50 | "error" to com.google.android.material.R.attr.colorError, 51 | "errorContainer" to com.google.android.material.R.attr.colorErrorContainer, 52 | "onError" to com.google.android.material.R.attr.colorOnError, 53 | "onErrorContainer" to com.google.android.material.R.attr.colorOnErrorContainer, 54 | 55 | "outline" to com.google.android.material.R.attr.colorOutline, 56 | "outlineVariant" to com.google.android.material.R.attr.colorOutlineVariant, 57 | 58 | "background" to android.R.attr.colorBackground, 59 | "onBackground" to com.google.android.material.R.attr.colorOnBackground, 60 | 61 | "surface" to com.google.android.material.R.attr.colorSurface, 62 | "onSurface" to com.google.android.material.R.attr.colorOnSurface, 63 | "surfaceDim" to com.google.android.material.R.attr.colorSurfaceDim, 64 | "surfaceBright" to com.google.android.material.R.attr.colorSurfaceBright, 65 | "surfaceVariant" to com.google.android.material.R.attr.colorSurfaceVariant, 66 | "onSurfaceVariant" to com.google.android.material.R.attr.colorOnSurfaceVariant, 67 | "surfaceInverse" to com.google.android.material.R.attr.colorSurfaceInverse, 68 | "onSurfaceInverse" to com.google.android.material.R.attr.colorOnSurfaceInverse, 69 | 70 | "surfaceContainer" to com.google.android.material.R.attr.colorSurfaceContainer, 71 | "surfaceContainerLow" to com.google.android.material.R.attr.colorSurfaceContainerLow, 72 | "surfaceContainerLowest" to com.google.android.material.R.attr.colorSurfaceContainerLowest, 73 | "surfaceContainerHigh" to com.google.android.material.R.attr.colorSurfaceContainerHigh, 74 | "surfaceContainerHighest" to com.google.android.material.R.attr.colorSurfaceContainerHighest, 75 | ).forEach { (colorRole, colorAttr) -> 76 | logColorResource(mode.name.lowercase(), colorRole, colorAttr) 77 | } 78 | } 79 | 80 | private fun Context.logColorResource(mode: String, colorRole: String, colorAttr: Int) { 81 | val colorHex = String.format("#%06X", 0xFFFFFF and MaterialColors.getColor(this, colorAttr, 0)) 82 | 83 | logI { 84 | """$colorHex""" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/optimization/OptimizationRemoverSheet.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views.optimization 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.activity.result.ActivityResultLauncher 10 | import androidx.activity.result.contract.ActivityResultContracts 11 | import androidx.core.view.children 12 | import androidx.fragment.app.FragmentManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import androidx.viewpager2.widget.ViewPager2 15 | import com.anissan.battarang.databinding.SheetOptimizationRemoverBinding 16 | import com.anissan.battarang.ui.views.optimization.tabs.PermissionTabViewHolder 17 | import com.anissan.battarang.ui.views.optimization.tabs.TabAdapter 18 | import com.anissan.battarang.ui.views.optimization.tabs.TutorialTabViewHolder 19 | import com.google.android.material.bottomsheet.BottomSheetBehavior 20 | import com.google.android.material.bottomsheet.BottomSheetDialog 21 | import com.google.android.material.bottomsheet.BottomSheetDialogFragment 22 | import com.google.android.material.tabs.TabLayout 23 | import com.google.android.material.tabs.TabLayoutMediator 24 | 25 | /** 26 | * This [BottomSheetDialogFragment] holds the ViewPager which in turns holds the two tabs. 27 | * */ 28 | class OptimizationRemoverSheet : BottomSheetDialogFragment() { 29 | 30 | companion object { 31 | fun show(fragmentManager: FragmentManager) { 32 | OptimizationRemoverSheet().show(fragmentManager, "optimization_remover_sheet") 33 | } 34 | } 35 | 36 | private val activityResultLauncher: ActivityResultLauncher = 37 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} 38 | 39 | private val tabAdapter = TabAdapter(activityResultLauncher) 40 | private var viewBindings: SheetOptimizationRemoverBinding? = null 41 | 42 | private val tabChangeCallback = object : ViewPager2.OnPageChangeCallback() { 43 | override fun onPageSelected(position: Int) { 44 | when (position) { 45 | // Expanding the "Tutorial" tab to its full height, otherwise it won't get expanded upon dragging. 46 | // See {setupTabViewPager} for more details. 47 | 1 -> (dialog as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED 48 | } 49 | } 50 | } 51 | 52 | override fun onCreateView( 53 | inflater: LayoutInflater, 54 | container: ViewGroup?, 55 | savedInstanceState: Bundle?, 56 | ): View = SheetOptimizationRemoverBinding.inflate(inflater, container, false).run { 57 | viewBindings = this 58 | 59 | // Bottom sheet's default auto peek height on widescreen is very low. 60 | // Setting it around 67% of the screen height. 61 | (dialog as BottomSheetDialog).behavior.peekHeight = 62 | (resources.displayMetrics.heightPixels / 1.5).toInt() 63 | 64 | setupTabViewPager() 65 | 66 | root 67 | } 68 | 69 | override fun onStart() { 70 | super.onStart() 71 | 72 | // The sheet dialog is initially set to WRAP_CONTENT. Works as expected when the bottom sheet has only one layout. 73 | // But when there's ViewPager with different sized tabs, the dialog leaves a big gap at the bottom. 74 | dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)?.layoutParams?.height = 75 | ViewGroup.LayoutParams.MATCH_PARENT 76 | } 77 | 78 | @SuppressLint("NotifyDataSetChanged") 79 | override fun onResume() { 80 | super.onResume() 81 | 82 | // Refreshes the "Permission" tab to sync the Switch states after returning. 83 | // Tried calling the more specific version: `notifyItemChanged(0)`, but it freezes the 84 | // the bottom sheet dragging behavior somehow. 85 | tabAdapter.notifyDataSetChanged() 86 | } 87 | 88 | override fun onDestroy() { 89 | viewBindings?.tabViewPager?.unregisterOnPageChangeCallback(tabChangeCallback) 90 | viewBindings = null 91 | 92 | super.onDestroy() 93 | } 94 | 95 | private fun SheetOptimizationRemoverBinding.setupTabViewPager() { 96 | tabViewPager.run { 97 | adapter = tabAdapter 98 | 99 | registerOnPageChangeCallback(tabChangeCallback) 100 | 101 | // ViewPager2 internally uses a RecyclerView to manage the pages. But it is scrollable; so 102 | // the bottom sheet underneath don't expand or collapse upon dragging. Turning it off so that 103 | // bottom sheet don't stuck to the default height. But it only works for the "Permission" tab, 104 | // "Tutorial" tab won't expand on dragging. 105 | // As a workaround, I'm manually expanding the "Tutorial" tab on {tabChangeCallback}. 106 | for (view: View in children) { 107 | if (view is RecyclerView) { 108 | view.isNestedScrollingEnabled = false 109 | view.overScrollMode = View.OVER_SCROLL_NEVER 110 | 111 | break 112 | } 113 | } 114 | } 115 | 116 | // Linking up the Tab names and swiping behavior with the ViewPager. 117 | TabLayoutMediator(tabLayout, tabViewPager) { tab: TabLayout.Tab, position: Int -> 118 | when (position) { 119 | 0 -> tab.text = PermissionTabViewHolder.TAB_TITLE 120 | 1 -> tab.text = TutorialTabViewHolder.TAB_TITLE 121 | } 122 | }.attach() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /app/src/main/res/values-ar/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | خدمة الإشعارات 5 | 6 | اسم الجهاز 7 | 8 | الإشعار عند 9 | مستوى البطارية يصل إلى ~% 10 | شريط التمرير لضبط مستوى البطارية 11 | البطارية منخفضة 12 | لكن يجب 13 | تجاوز أثناء تشغيل الشاشة 14 | 15 | فصل الاقتران 16 | اضغط واستمر لفترة وجيزة لفصل الاقتران 17 | 18 | إرسال إشعار تجريبي 19 | فشل الشبكة!\nتحقق مما إذا كان الإنترنت يعمل. 20 | 21 | حول 22 | 23 | 24 | 25 | خدمة الإشعارات 26 | من الآمن إيقاف أو تصغير هذا القناة 27 | 28 | بدأت خدمة إشعارات باتارانغ 29 | انتظار أحداث البطارية 30 | هذه الخدمة الخاملة ضرورية للحفاظ على تشغيل خدمة الإشعارات إلى أجل غير مسمى. من الآمن تصغير أو إيقاف هذا الإشعار من الإعدادات. 31 | 32 | إخفاء هذا الإشعار 33 | 34 | 35 | 36 | اقتران مع الجهاز 37 | اختر جهاز استقبال واحد 38 | بوت تيليجرام 39 | لم يتم تثبيت تيليجرام. حاول الرابط الآخر. 40 | اختر خدمة استقبال لتوليد رمز الاقتران ثم قم بمسح أو لصق الرمز هنا.\n\nإذا نجح الاقتران، ستتلقى تأكيدًا على جهاز الاستقبال. 41 | لصق بدلاً من ذلك 42 | مسح QR 43 | 44 | رمز الاقتران غير صالح.\nتأكد من مسحه أو نسخه بشكل صحيح. 45 | لا يمكن فتح الكاميرا.\nانسخ الرمز يدويًا واضغط على "لصق بدلاً من ذلك". 46 | يجب توفير إذن الكاميرا من إعدادات التطبيق. 47 | مسح رمز الاقتران 48 | 49 | 50 | 51 | تحديث الخدمة 52 | يُعلم عند وجود أي تحديث مهم 53 | 54 | يجب تمكين الإشعارات 55 | إذا كان هناك تحديث جديد للتطبيق أو تم فصل الجهاز المستقبل لأي سبب، لن يتمكن باتارانغ من إشعارك بدون هذا الإذن. 56 | 57 | 58 | ابقِه قيد التشغيل 59 | التطبيقات التلقائية مثل هذا تخضع لقواعد تحسين البطارية التي يفرضها مصنعو الأجهزة وأندرويد نفسه.\n\nنتيجة لذلك، قد يقوم نظامك بمقاطعة خدمة الإشعارات من التشغيل إلى أجل غير مسمى، حتى وإن كانت تنتظر فقط أحداث البطارية دون القيام بأي إجراءات. 60 | إزالة هذه القيود 61 | 62 | إزالة قيود البطارية 63 | 64 | 65 | 66 | تجاهل تحسين البطارية 67 | إذا لم يتم استخدام جهازك لفترة طويلة، قد يقوم النظام بإيقاف هذه الخدمة حتى يكون الجهاز نشطًا مرة أخرى. لن تتلقى أي إشعارات خلال ذلك الوقت إذا لم يتم منح هذا الإذن. 68 | 69 | تعطيل سبات التطبيق 70 | إذا لم تقم بفتح تطبيق بانتظام، قد يقوم النظام بإيقافه حتى تقوم بفتحه مرة أخرى. نظرًا لأن هذا تطبيق تلقائي لا يتطلب تفاعل المستخدم، من المهم تعطيل السبات لهذا التطبيق. 71 | 72 | السماح بالتشغيل التلقائي 73 | بعض الشركات المصنعة تحتفظ بقائمة بيضاء من التطبيقات المعروفة التي يمكنها استئناف خدماتها بعد إعادة تشغيل الهاتف. لن تتمكن هذه الخدمة من إعادة التشغيل إذا لم يتم السماح بذلك بشكل صريح. 74 | 75 | السماح بهذه الخيارات يجب أن يكون كافيًا لخدمة مستمرة. إذا لم يكن كذلك، يرجى زيارة علامة التبويب التعليمية للحصول على إرشادات شاملة خطوة بخطوة. 76 | 77 | 78 | مراجعة 79 | مُخطِر باتارانغ 80 | يرسل إشعارات البطارية إلى 81 | جهاز الاستقبال 82 | أو 83 | تيليجرام 84 | المصدر 85 | القضايا 86 | تصميم وتطوير بواسطة 87 | نيسان أحمد 88 | 🇧🇩 89 | تابعني على تويتر 90 | تواصل معي على لينكد إن 91 | تحقق من مشاريعي الأخرى 92 | التراخيص 93 | سياسة الخصوصية 94 | شروط الخدمة 95 | لم يتم العثور على متصفح على الجهاز لفتح الرابط. 96 | 97 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Battarang 3 | @string/app_name 4 | 5 | 6 | 7 | Notifier Service 8 | 9 | Device Name 10 | 11 | Notify When 12 | Battery level reaches ~% 13 | Slider for adjusting the battery level 14 | Battery is low 15 | But Must 16 | Skip while display is on 17 | 18 | Unpair 19 | Tap & Hold for a moment to Unpair 20 | 21 | Send a test notification 22 | Network Failed!\nCheck if your internet is working. 23 | 24 | About 25 | 26 | 27 | 28 | Notifier Service 29 | It is safe to turn off or minimize this channel 30 | 31 | Started Battarang Notifier service 32 | Waiting for battery events 33 | This idle service is necessary to keep the notifier service running indefinitely. It\'s safe to minimize or turn off this notification from settings. 34 | 35 | Hide this notification 36 | 37 | 38 | 39 | Pair Receiver 40 | Choose one Receiver 41 | Telegram Bot 42 | Telegram isn\'t installed. Try the other link. 43 | Select a receiver service to generate a pairing code and then scan or paste the code here.\n\nIf pairing is successful, you\'ll receive a confirmation on the receiver device. 44 | Paste instead 45 | Scan QR 46 | 47 | Pairing code is invalid.\nMake sure it\'s correctly scanned or copied. 48 | Can\'t open Camera.\nCopy the code manually and "Paste instead". 49 | You need to provide the camera permission from app settings. 50 | Scan pairing code 51 | 52 | 53 | 54 | Service Update 55 | Notifies when there\'s any critical update 56 | 57 | Notification should be enabled 58 | If there\'s a new app update or the Receiver becomes unpaired for any reason, Battarang won\'t be able to notify you without this permission. 59 | 60 | 61 | Keep it Running 62 | Automation apps like this are subject to battery optimization rules imposed by both device makers and Android itself.\n\nAs a result, your system may interrupt the notifier service from running indefinitely, even though it only waits for battery events without performing any actions. 63 | Remove these Restrictions 64 | 65 | Remove Battery Restrictions 66 | 67 | 68 | 69 | Ignore Battery Optimization 70 | If your device is unused for a long period of time, the system may pause this service until the device is active again. You won\'t receive any notifications during that time if this permission is not given. 71 | 72 | Disable App Hibernation 73 | If you don\'t open an app regularly, the system may pause it until you open it again. Since this is an automation app that doesn\'t require user interaction, it’s important to disable hibernation for this app. 74 | 75 | Allow Auto-start 76 | Some OEMs keep a whitelist of known apps that can resume their services after a phone restart. This service won\'t be able to restart if it\'s not allowed explicitly. 77 | 78 | Allowing these should be sufficient for a consistent service. If it\'s not, please visit the Tutorial tab for more comprehensive step-by-step instructions. 79 | 80 | 81 | Review 82 | Battarang Notifier 83 | Sends battery notifications to 84 | Receiver 85 | or 86 | Telegram 87 | Source 88 | Issues 89 | Designed & Developed by 90 | Nissan A. 91 | 🇧🇩 92 | Follow me on Twitter 93 | Connect on LinkedIn 94 | Check out my other projects 95 | Licenses 96 | Privacy Policy 97 | Terms of Service 98 | No browser found on the device to open the link. 99 | 100 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/views/optimization/tabs/PermissionTabViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui.views.optimization.tabs 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Build 7 | import android.os.PowerManager 8 | import android.provider.Settings 9 | import android.view.MotionEvent 10 | import android.view.View 11 | import android.widget.Toast 12 | import androidx.activity.result.ActivityResultLauncher 13 | import androidx.core.content.ContextCompat 14 | import androidx.core.content.IntentCompat 15 | import androidx.core.content.PackageManagerCompat 16 | import androidx.core.content.UnusedAppRestrictionsConstants 17 | import androidx.core.net.toUri 18 | import androidx.recyclerview.widget.RecyclerView 19 | import com.anissan.battarang.BuildConfig 20 | import com.anissan.battarang.databinding.TabPermissionBinding 21 | import com.google.android.material.card.MaterialCardView 22 | import com.google.android.material.materialswitch.MaterialSwitch 23 | import com.google.common.util.concurrent.ListenableFuture 24 | import com.judemanutd.autostarter.AutoStartPermissionHelper 25 | import dev.chrisbanes.insetter.applyInsetter 26 | 27 | class PermissionTabViewHolder( 28 | private val permissionBinding: TabPermissionBinding, 29 | private val activityResultLauncher: ActivityResultLauncher, 30 | ) : 31 | RecyclerView.ViewHolder(permissionBinding.root) { 32 | 33 | companion object { 34 | const val TAB_TITLE = "Permissions" 35 | } 36 | 37 | init { 38 | permissionBinding.apply { 39 | permissionOptionsLayout.applyInsetter { 40 | type(navigationBars = true) { 41 | margin(vertical = true) 42 | } 43 | } 44 | 45 | setupIgnoreOptimizationSwitch() 46 | setupDisableHibernation() 47 | setupAutoStarter() 48 | } 49 | } 50 | 51 | @SuppressLint("BatteryLife") 52 | private fun TabPermissionBinding.setupIgnoreOptimizationSwitch() { 53 | ignoreBatteryOptimizationSwitch.run { 54 | if (Build.VERSION.SDK_INT < 23) { 55 | ignoreBatteryOptimizationCard.disableCard() 56 | isEnabled = false 57 | return 58 | } 59 | 60 | disableSwitchDragging() 61 | bindClicksFrom(ignoreBatteryOptimizationCard) 62 | 63 | setOnClickListener { switch: View -> 64 | val intent = Intent() 65 | 66 | if ((switch as MaterialSwitch).isChecked) { 67 | intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 68 | intent.data = "package:${BuildConfig.APPLICATION_ID}".toUri() 69 | } else { 70 | intent.action = Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS 71 | } 72 | 73 | context.startActivity(intent) 74 | } 75 | 76 | refreshIgnoreOptimizationSwitch() 77 | } 78 | } 79 | 80 | private fun MaterialSwitch.refreshIgnoreOptimizationSwitch() { 81 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 82 | isChecked = 83 | (context.getSystemService(Context.POWER_SERVICE) as PowerManager).isIgnoringBatteryOptimizations( 84 | BuildConfig.APPLICATION_ID 85 | ) 86 | } 87 | } 88 | 89 | 90 | private fun TabPermissionBinding.setupDisableHibernation() { 91 | disableHibernationSwitch.run { 92 | disableSwitchDragging() 93 | 94 | bindClicksFrom(disableHibernationCard) 95 | 96 | disableHibernationCard.disableIfAppHibernationUnSupported(this) 97 | 98 | val optionName = when (Build.VERSION.SDK_INT) { 99 | in 23..30 -> "Remove permissions if app isn't used" 100 | 31, 32 -> "Remove permissions and free up space" 101 | else -> "Pause app activity if unused" 102 | } 103 | 104 | setOnClickListener { 105 | val intent = 106 | IntentCompat.createManageUnusedAppRestrictionsIntent(context, BuildConfig.APPLICATION_ID) 107 | 108 | activityResultLauncher.launch(intent) 109 | 110 | val onOffText: String = if (isChecked) "OFF" else "ON" 111 | Toast.makeText(context, "Turn $onOffText: \"$optionName\"", Toast.LENGTH_LONG).show() 112 | } 113 | } 114 | } 115 | 116 | private fun MaterialCardView.disableIfAppHibernationUnSupported(materialSwitch: MaterialSwitch) { 117 | val future: ListenableFuture = PackageManagerCompat.getUnusedAppRestrictionsStatus(context) 118 | try { 119 | future.addListener( 120 | { 121 | when (future.get()) { 122 | UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE -> { 123 | materialSwitch.isEnabled = false 124 | disableCard() 125 | } 126 | 127 | UnusedAppRestrictionsConstants.DISABLED -> materialSwitch.isChecked = true 128 | } 129 | }, 130 | ContextCompat.getMainExecutor(context) 131 | ) 132 | } catch (_: Exception) { 133 | materialSwitch.isEnabled = false 134 | disableCard() 135 | } 136 | } 137 | 138 | private fun MaterialSwitch.refreshDisableHibernationSwitch() { 139 | val future: ListenableFuture = PackageManagerCompat.getUnusedAppRestrictionsStatus(context) 140 | try { 141 | future.addListener( 142 | { isChecked = future.get() == UnusedAppRestrictionsConstants.DISABLED }, 143 | ContextCompat.getMainExecutor(context) 144 | ) 145 | } catch (_: Exception) { 146 | isChecked = false 147 | } 148 | } 149 | 150 | private fun TabPermissionBinding.setupAutoStarter() { 151 | val context = root.context 152 | val autoStartPermissionHelper = AutoStartPermissionHelper.getInstance() 153 | 154 | if (autoStartPermissionHelper.isAutoStartPermissionAvailable(context, false).not()) { 155 | allowAutoStartCard.disableCard() 156 | } 157 | 158 | allowAutoStartCard.setOnClickListener { 159 | autoStartPermissionHelper.getAutoStartPermission(context, open = true) 160 | } 161 | } 162 | 163 | @SuppressLint("ClickableViewAccessibility") 164 | private fun MaterialSwitch.disableSwitchDragging() { 165 | setOnTouchListener { _, event -> 166 | event.actionMasked == MotionEvent.ACTION_MOVE 167 | } 168 | } 169 | 170 | /** 171 | * Checkbox have a ripple animation set only on its checkmark icon, not the text beside it. 172 | * Setting a custom ripple on the whole checkbox doesn't work if margin is applied and the ripple won't reach the edges. 173 | * The only workaround I found is to use a full bleed wrapper card (which already has a nice ripple effect built-in) 174 | * and disabling the checkbox so that the card behind gets the click. But then I have to programmatically 175 | * pass the card clicks to the checkbox. 176 | */ 177 | private fun MaterialSwitch.bindClicksFrom(card: MaterialCardView) { 178 | card.setOnClickListener { performClick() } 179 | } 180 | 181 | private fun MaterialCardView.disableCard() { 182 | isEnabled = false 183 | alpha = 0.5f 184 | } 185 | 186 | fun refreshSwitchStates() { 187 | permissionBinding.ignoreBatteryOptimizationSwitch.refreshIgnoreOptimizationSwitch() 188 | permissionBinding.disableHibernationSwitch.refreshDisableHibernationSwitch() 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.ui 2 | 3 | import android.content.Intent 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.view.View 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.app.NotificationManagerCompat 10 | import androidx.core.view.WindowCompat 11 | import androidx.core.view.isVisible 12 | import com.anissan.battarang.R 13 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers 14 | import com.anissan.battarang.background.services.BroadcastReceiverRegistererService 15 | import com.anissan.battarang.data.LocalKvStore 16 | import com.anissan.battarang.data.PrefKey 17 | import com.anissan.battarang.databinding.ActivityMainBinding 18 | import com.anissan.battarang.network.MessageType 19 | import com.anissan.battarang.network.ReceiverApiClient 20 | import com.anissan.battarang.ui.views.about.AboutSheet 21 | import com.anissan.battarang.ui.views.optimization.restoreOptimizationRequestDialogState 22 | import com.anissan.battarang.ui.views.optimization.saveOptimizationRequestDialogState 23 | import com.anissan.battarang.ui.views.optimization.showOptimizationRequestDialog 24 | import com.anissan.battarang.ui.views.pairing.registerQrScanner 25 | import com.anissan.battarang.ui.views.pairing.restorePairingDialogState 26 | import com.anissan.battarang.ui.views.pairing.savePairingDialogState 27 | import com.anissan.battarang.ui.views.saveEditedText 28 | import com.anissan.battarang.ui.views.setupAppBar 29 | import com.anissan.battarang.ui.views.setupButtonBar 30 | import com.anissan.battarang.ui.views.setupDeviceNameInput 31 | import com.anissan.battarang.ui.views.setupLowBatteryLevelCheckbox 32 | import com.anissan.battarang.ui.views.setupMaxBatteryLevelCheckbox 33 | import com.anissan.battarang.ui.views.setupNotifierServiceToggle 34 | import com.anissan.battarang.ui.views.setupPairReceiverFab 35 | import com.anissan.battarang.ui.views.setupSkipIfDisplayOnToggleCheckbox 36 | import com.google.android.material.snackbar.Snackbar 37 | import org.koin.android.ext.android.inject 38 | 39 | class MainActivity : AppCompatActivity() { 40 | val localKvStore: LocalKvStore by inject() 41 | val receiverApiClient: ReceiverApiClient by inject() 42 | 43 | lateinit var binding: ActivityMainBinding 44 | 45 | val paired: Boolean 46 | get() = localKvStore.receiverToken.isNullOrBlank().not() 47 | 48 | override fun onCreate(savedInstanceState: Bundle?) { 49 | super.onCreate(savedInstanceState) 50 | 51 | WindowCompat.enableEdgeToEdge(window) 52 | 53 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 54 | // Otherwise a translucent scrim will be applied on three-button navigation bar. 55 | window.isNavigationBarContrastEnforced = false 56 | } 57 | 58 | binding = ActivityMainBinding.inflate(layoutInflater) 59 | setContentView(binding.root) 60 | 61 | registerQrScanner() 62 | 63 | /* Each `setup*` function has three responsibilities: 64 | 1. Dynamically adjust the View paddings and margins, 65 | 2. Initialize the View from its saved states, and 66 | 3. Registers the event listeners. 67 | */ 68 | 69 | setupAppBar() 70 | 71 | setupNotifierServiceToggle() 72 | 73 | setupDeviceNameInput() 74 | 75 | setupMaxBatteryLevelCheckbox() 76 | setupLowBatteryLevelCheckbox() 77 | setupSkipIfDisplayOnToggleCheckbox() 78 | 79 | setupButtonBar() 80 | 81 | setupPairReceiverFab() 82 | } 83 | 84 | override fun onStart() { 85 | super.onStart() 86 | 87 | localKvStore.startObservingChanges { key: String -> 88 | when (key) { 89 | PrefKey.RECEIVER_TOKEN.name -> refreshAfterTokenUpdate() 90 | 91 | PrefKey.NOTIFICATION_SERVICE_TOGGLE.name, 92 | PrefKey.MAX_LEVEL_NOTIFICATION_TOGGLE.name, 93 | PrefKey.LOW_BATTERY_NOTIFICATION_TOGGLE.name, 94 | -> refreshServiceState() 95 | } 96 | } 97 | } 98 | 99 | private fun refreshAfterTokenUpdate() { 100 | binding.run { 101 | if (paired) { 102 | localKvStore.isMonitoringServiceEnabled = true 103 | 104 | unpairButton.visibility = View.VISIBLE 105 | testButton.visibility = View.VISIBLE 106 | serviceNameText.text = localKvStore.pairedServiceName 107 | 108 | pairReceiverFab.hide() 109 | 110 | receiverApiClient.sendNotification(MessageType.PAIRED) { response: String? -> 111 | if (response == null) { 112 | Toast.makeText(this@MainActivity, R.string.network_error, Toast.LENGTH_LONG).show() 113 | } 114 | } 115 | 116 | showOptimizationRequestDialog() 117 | } else { 118 | localKvStore.isMonitoringServiceEnabled = false 119 | localKvStore.pairedServiceTag = null 120 | 121 | unpairButton.visibility = View.GONE 122 | testButton.visibility = View.GONE 123 | serviceNameText.text = "" 124 | 125 | pairReceiverFab.show() 126 | } 127 | } 128 | } 129 | 130 | fun refreshServiceState() { 131 | BroadcastReceiverRegistererService.stop(this@MainActivity) 132 | 133 | val isAnyNotifyOptionChecked = 134 | localKvStore.isMaxLevelNotificationEnabled || localKvStore.isLowBatteryNotificationEnabled 135 | 136 | val shouldServiceBeEnabled = isAnyNotifyOptionChecked && paired 137 | val shouldServiceStart = shouldServiceBeEnabled && localKvStore.isMonitoringServiceEnabled 138 | 139 | binding.notifierServiceCard.apply { 140 | isEnabled = isAnyNotifyOptionChecked 141 | alpha = if (shouldServiceBeEnabled) 1f else 0.6f 142 | } 143 | 144 | binding.notifierServiceSwitch.apply { 145 | isEnabled = shouldServiceBeEnabled 146 | isChecked = shouldServiceStart 147 | } 148 | 149 | if (shouldServiceStart) BroadcastReceiverRegistererService.start(this@MainActivity) 150 | } 151 | 152 | override fun onStop() { 153 | super.onStop() 154 | 155 | localKvStore.stopObservingChanges() 156 | } 157 | 158 | override fun onNewIntent(intent: Intent) { 159 | super.onNewIntent(intent) 160 | 161 | /** 162 | * Notification actions are set from here: 163 | * @see com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers.notifyUpdates 164 | */ 165 | when (intent.action) { 166 | "Update" -> AboutSheet.show(supportFragmentManager) 167 | "Unpair" -> localKvStore.receiverToken = null 168 | } 169 | 170 | NotificationManagerCompat.from(this).cancel(BroadcastedEventHandlers.NOTIFICATION_ID) 171 | } 172 | 173 | override fun onSaveInstanceState(outState: Bundle) { 174 | binding.deviceNameInput.run { 175 | if (hasFocus()) saveEditedText(localKvStore) 176 | } 177 | 178 | savePairingDialogState(outState) 179 | saveOptimizationRequestDialogState(outState) 180 | 181 | super.onSaveInstanceState(outState) 182 | } 183 | 184 | override fun onRestoreInstanceState(savedInstanceState: Bundle) { 185 | super.onRestoreInstanceState(savedInstanceState) 186 | 187 | restorePairingDialogState(savedInstanceState) 188 | restoreOptimizationRequestDialogState(savedInstanceState) 189 | } 190 | 191 | fun showSnackbar(stringResId: Int, length: Int = Snackbar.LENGTH_LONG) { 192 | showSnackbar(getString(stringResId), length) 193 | } 194 | 195 | fun showSnackbar(text: String, length: Int = Snackbar.LENGTH_LONG) { 196 | binding.run { 197 | Snackbar.make(root, text, length).apply { 198 | anchorView = 199 | if (pairReceiverFab.isVisible) pairReceiverFab else buttonBarCard 200 | }.show() 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/background/receivers/handlers/BroadcastedEventHandlers.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.background.receivers.handlers 2 | 3 | import android.Manifest 4 | import android.app.AlarmManager 5 | import android.app.NotificationChannel 6 | import android.app.NotificationManager 7 | import android.app.PendingIntent 8 | import android.content.Context 9 | import android.content.Intent 10 | import android.content.pm.PackageManager 11 | import android.hardware.display.DisplayManager 12 | import android.os.BatteryManager 13 | import android.os.Build 14 | import android.os.SystemClock 15 | import android.view.Display 16 | import androidx.core.app.NotificationCompat 17 | import androidx.core.app.NotificationManagerCompat 18 | import androidx.core.content.ContextCompat 19 | import com.anissan.battarang.R 20 | import com.anissan.battarang.background.receivers.BatteryLevelCheckerAlarmReceiver 21 | import com.anissan.battarang.data.LocalKvStore 22 | import com.anissan.battarang.network.MessageType 23 | import com.anissan.battarang.network.ReceiverApiClient 24 | import com.anissan.battarang.ui.MainActivity 25 | import com.anissan.battarang.utils.logV 26 | 27 | class BroadcastedEventHandlers( 28 | private val context: Context, 29 | private val localKvStore: LocalKvStore, 30 | private val receiverApiClient: ReceiverApiClient, 31 | ) { 32 | private val batteryLevelPollingAlarm: AlarmManager = 33 | context.getSystemService(Context.ALARM_SERVICE) as AlarmManager 34 | 35 | private val alarmId: Int = 64 36 | 37 | private val levelCheckerIntent: Intent = 38 | Intent(context, BatteryLevelCheckerAlarmReceiver::class.java) 39 | .setAction(BatteryLevelCheckerAlarmReceiver.ACTION_CHECK_BATTERY_LEVEL) 40 | 41 | // From Android 12+, it is mandatory to add a mutability flag on pending intents. 42 | // FLAG_IMMUTABLE added in API 23. 43 | private val pendingIntentFlag: Int = 44 | if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) { 45 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE 46 | } else { 47 | PendingIntent.FLAG_CANCEL_CURRENT 48 | } 49 | 50 | private val currentBatteryLevel: Int 51 | get() = (context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager) 52 | .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) 53 | 54 | fun startBatteryLevelPollingAlarm() { 55 | if (currentBatteryLevel > localKvStore.maxChargingLevelPercentage) { 56 | logV { "Charger Connected: But not setting the alarm because battery level is already ahead of the preferred level." } 57 | return 58 | } 59 | 60 | batteryLevelPollingAlarm.setRepeating( 61 | AlarmManager.ELAPSED_REALTIME_WAKEUP, 62 | SystemClock.elapsedRealtime(), 63 | 60 * 1_000L, // 1 minute 64 | PendingIntent.getBroadcast(context, alarmId, levelCheckerIntent, pendingIntentFlag), 65 | ) 66 | 67 | logV { "Charger Connected: Starting an Alarm to check the battery level at a minute interval..." } 68 | } 69 | 70 | fun stopBatteryLevelPollingAlarm() { 71 | logV { "Requested to stop the periodic alarm" } 72 | 73 | batteryLevelPollingAlarm.cancel( 74 | PendingIntent.getBroadcast(context, alarmId, levelCheckerIntent, pendingIntentFlag) 75 | ) 76 | 77 | logV { "Stopped the periodic battery level checker alarm" } 78 | } 79 | 80 | fun notifyBatteryIsLow() { 81 | val batteryLevel: Int = currentBatteryLevel 82 | logV { "Battery Level: $batteryLevel" } 83 | 84 | if (batteryLevel > 20) return 85 | 86 | if (shouldSkipNotification()) { 87 | logV { "Skipping sending battery low notification as the display is on" } 88 | return 89 | } 90 | 91 | receiverApiClient.sendNotification(MessageType.LOW, null, ::notifyUpdates) 92 | } 93 | 94 | fun notifyIfMaxLevelReached() { 95 | val batteryLevel: Int = currentBatteryLevel 96 | logV { "Battery Level: $batteryLevel" } 97 | 98 | if (batteryLevel < localKvStore.maxChargingLevelPercentage) return 99 | 100 | if (shouldSkipNotification()) { 101 | logV { "Skipping sending battery max reached notification as the display is on" } 102 | return 103 | } 104 | 105 | stopBatteryLevelPollingAlarm() 106 | 107 | receiverApiClient.sendNotification(MessageType.FULL, batteryLevel, ::notifyUpdates) 108 | } 109 | 110 | 111 | /** 112 | * Determines if notifications should be sent based on user preference and the current display state. 113 | * */ 114 | private fun shouldSkipNotification(): Boolean { 115 | if (localKvStore.isSkipWhileDisplayOnEnabled) { 116 | // Make sure every display is OFF before notifying. 117 | return (context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) 118 | .displays 119 | .any { display: Display -> display.state == Display.STATE_ON } 120 | } 121 | 122 | return false 123 | } 124 | 125 | companion object { 126 | const val NOTIFICATION_ID = 404 127 | } 128 | 129 | private fun notifyUpdates(responseBody: String?) { 130 | if (ContextCompat.checkSelfPermission( 131 | context, 132 | Manifest.permission.POST_NOTIFICATIONS, 133 | ) != PackageManager.PERMISSION_GRANTED 134 | ) { 135 | logV { "Skipping notifying updates as user has explicitly disabled notifications from app settings" } 136 | return 137 | } 138 | 139 | // A server response delimited with `||` means it should be posted as notification. 140 | val message = responseBody?.split("||") 141 | if (message?.size != 3) return 142 | 143 | val (action, title, description) = message.map { it.trim() } 144 | 145 | // A new activity will be created on every notification click regardless of an existing activity. 146 | val notificationTapActionPendingIntent: PendingIntent = PendingIntent.getActivity( 147 | context, 148 | 128, 149 | Intent(context, MainActivity::class.java), 150 | PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, 151 | ) 152 | 153 | val notificationActionPendingIntent: PendingIntent = PendingIntent.getActivity( 154 | context, 155 | 129, 156 | Intent(context, MainActivity::class.java).setAction(action), 157 | PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, 158 | ) 159 | 160 | val notification = NotificationCompat.Builder(context, context.createServiceUpdateChannel()) 161 | .setAutoCancel(true) 162 | .setContentTitle(title) 163 | .setStyle(NotificationCompat.BigTextStyle().bigText(description)) 164 | .setSmallIcon(R.drawable.ic_notification_service) 165 | .setContentIntent(notificationTapActionPendingIntent) 166 | .addAction(0, action, notificationActionPendingIntent) 167 | .build() 168 | 169 | NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) 170 | } 171 | 172 | /** 173 | * This function is safe to call multiple times as the Channels get created only once. 174 | */ 175 | private fun Context.createServiceUpdateChannel(): String { 176 | val channelId = "SERVICE_UPDATE" 177 | 178 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 179 | (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel( 180 | NotificationChannel( 181 | channelId, 182 | getString(R.string.service_update_channel), 183 | NotificationManager.IMPORTANCE_DEFAULT, 184 | ).apply { 185 | description = getString(R.string.service_update_channel_description) 186 | setShowBadge(true) 187 | } 188 | ) 189 | } 190 | 191 | return channelId 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 16 | 21 | 26 | 27 | 28 | 61 | 62 | 63 | 66 | 67 | 69 | 70 | 71 | 75 | 76 | 77 | 191 | 192 | 199 | 200 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/background/services/BroadcastReceiverRegistererService.kt: -------------------------------------------------------------------------------- 1 | package com.anissan.battarang.background.services 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.app.Service 8 | import android.content.ComponentName 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.content.IntentFilter 12 | import android.content.pm.PackageManager 13 | import android.os.BatteryManager 14 | import android.os.Build 15 | import android.os.IBinder 16 | import android.provider.Settings 17 | import androidx.annotation.RequiresApi 18 | import androidx.core.app.NotificationCompat 19 | import androidx.core.content.ContextCompat 20 | import com.anissan.battarang.R 21 | import com.anissan.battarang.background.receivers.BatteryLowReceiver 22 | import com.anissan.battarang.background.receivers.BootEventReceiver 23 | import com.anissan.battarang.background.receivers.ChargerConnectedReceiver 24 | import com.anissan.battarang.background.receivers.ChargerConnectionReceiver 25 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers 26 | import com.anissan.battarang.data.LocalKvStore 27 | import com.anissan.battarang.ui.MainActivity 28 | import com.anissan.battarang.utils.logV 29 | import org.koin.android.ext.android.inject 30 | 31 | /** 32 | * Most implicit broadcast receivers can not be declared in the manifest anymore. 33 | * This Service is responsible for registering the Receivers and listening 34 | * for the broadcasted events as long as the notification service is enabled. 35 | * 36 | * This service can be started and stopped easily with the helper functions in companion object. 37 | */ 38 | class BroadcastReceiverRegistererService : Service() { 39 | private val localKvStore: LocalKvStore by inject() 40 | private val batteryLowReceiver: BatteryLowReceiver by inject() 41 | private val chargerConnectedReceiver: ChargerConnectedReceiver by inject() 42 | private val chargerConnectionReceiver: ChargerConnectionReceiver by inject() 43 | private val broadcastedEventHandlers: BroadcastedEventHandlers by inject() 44 | 45 | override fun onCreate() { 46 | resumeServiceAfterBoot(true) 47 | 48 | // Background services can be killed by the System at anytime. 49 | // Since Oreo, foreground services with a persistent notification is required for long 50 | // running tasks. It's all in here: 51 | // https://developer.android.com/guide/components/foreground-services 52 | if (Build.VERSION.SDK_INT >= 26) startForeground(128, buildServiceNotification()) 53 | 54 | if (localKvStore.isLowBatteryNotificationEnabled) { 55 | ContextCompat.registerReceiver( 56 | this, 57 | batteryLowReceiver, 58 | IntentFilter(Intent.ACTION_BATTERY_LOW), 59 | ContextCompat.RECEIVER_NOT_EXPORTED, 60 | ) 61 | } 62 | 63 | if (localKvStore.isMaxLevelNotificationEnabled) { 64 | ContextCompat.registerReceiver( 65 | this, 66 | chargerConnectionReceiver, 67 | IntentFilter().apply { 68 | addAction(Intent.ACTION_POWER_CONNECTED) 69 | addAction(Intent.ACTION_POWER_DISCONNECTED) 70 | }, 71 | ContextCompat.RECEIVER_NOT_EXPORTED, 72 | ) 73 | 74 | /* Edge Case */ 75 | // If the charger is already connected before starting this service, then manually 76 | // start the battery level polling. 77 | 78 | val currentBatteryStatus: Int? = 79 | registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.getIntExtra( 80 | BatteryManager.EXTRA_STATUS, 81 | -1, 82 | ) 83 | 84 | if (currentBatteryStatus == BatteryManager.BATTERY_STATUS_CHARGING) { 85 | broadcastedEventHandlers.startBatteryLevelPollingAlarm() 86 | } 87 | } 88 | 89 | logV { "Registered the implicit Broadcast Receivers." } 90 | } 91 | 92 | override fun onDestroy() { 93 | resumeServiceAfterBoot(false) 94 | 95 | try { 96 | // If an alarm is already in progress, only stopping this service won't stop the alarm. 97 | broadcastedEventHandlers.stopBatteryLevelPollingAlarm() 98 | 99 | unregisterReceiver(chargerConnectionReceiver) 100 | unregisterReceiver(batteryLowReceiver) 101 | unregisterReceiver(chargerConnectedReceiver) 102 | } catch (_: Exception) { 103 | } 104 | 105 | super.onDestroy() 106 | } 107 | 108 | /** 109 | * Instead of managing a separate state for syncing the service status preference with 110 | * the receiver, we are going to enable or disable the boot receiver component itself. 111 | * So, if the boot receiver component is enabled, we can enable the notification service 112 | * straight away. 113 | * */ 114 | private fun resumeServiceAfterBoot(toggle: Boolean) { 115 | val state = 116 | if (toggle) PackageManager.COMPONENT_ENABLED_STATE_ENABLED 117 | else PackageManager.COMPONENT_ENABLED_STATE_DISABLED 118 | 119 | packageManager.setComponentEnabledSetting( 120 | ComponentName(this, BootEventReceiver::class.java), 121 | state, 122 | PackageManager.DONT_KILL_APP, 123 | ) 124 | 125 | val stateStatus: String = if (toggle) "enabled" else "disabled" 126 | logV { "Boot event Receiver Component is now $stateStatus." } 127 | } 128 | 129 | override fun onBind(intent: Intent): IBinder? = null 130 | 131 | companion object { 132 | private lateinit var thisServiceIntent: Intent 133 | 134 | fun start(context: Context) { 135 | thisServiceIntent = Intent(context, BroadcastReceiverRegistererService::class.java) 136 | 137 | if (Build.VERSION.SDK_INT >= 26) { 138 | context.startForegroundService(thisServiceIntent) 139 | } else { 140 | context.startService(thisServiceIntent) 141 | } 142 | 143 | logV { "Started this foreground service successfully" } 144 | } 145 | 146 | fun stop(context: Context) { 147 | if (Companion::thisServiceIntent.isInitialized) { 148 | context.stopService(thisServiceIntent) 149 | logV { "Stopped the foreground service." } 150 | } 151 | } 152 | } 153 | } 154 | 155 | @RequiresApi(Build.VERSION_CODES.O) 156 | private fun Context.buildServiceNotification(): Notification { 157 | val batteryStateChannelId = createNotificationServiceChannel() 158 | 159 | // A new activity will be created on every notification click regardless of an existing activity. 160 | val notificationTapActionPendingIntent: PendingIntent = PendingIntent.getActivity( 161 | this, 162 | 256, 163 | Intent(this, MainActivity::class.java), 164 | PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, 165 | ) 166 | 167 | val channelSettingsIntent: PendingIntent = PendingIntent.getActivity( 168 | this, 169 | 512, 170 | Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { 171 | putExtra(Settings.EXTRA_APP_PACKAGE, packageName) 172 | putExtra(Settings.EXTRA_CHANNEL_ID, batteryStateChannelId) 173 | }, 174 | PendingIntent.FLAG_IMMUTABLE, 175 | ) 176 | 177 | return NotificationCompat.Builder(this, batteryStateChannelId) 178 | .setContentTitle(getString(R.string.service_notification_content_title)) 179 | .setStyle( 180 | NotificationCompat.BigTextStyle() 181 | .bigText(getString(R.string.service_notification_content_text)) 182 | ) 183 | .setSmallIcon(R.drawable.ic_notification_service) 184 | .setTicker(getString(R.string.service_notification_ticker)) 185 | .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) 186 | .setContentIntent(notificationTapActionPendingIntent) 187 | .addAction( 188 | R.drawable.ic_visibility_off, 189 | getString(R.string.service_notification_channel_settings), 190 | channelSettingsIntent, 191 | ).build() 192 | } 193 | 194 | /** 195 | * This function is safe to call multiple times as the Channels get created only once. 196 | */ 197 | @RequiresApi(Build.VERSION_CODES.O) 198 | private fun Context.createNotificationServiceChannel(): String { 199 | val channelId = "PUSH_NOTIFIER_SERVICE" 200 | 201 | val notifierServiceChannel: NotificationChannel = NotificationChannel( 202 | channelId, 203 | getString(R.string.notifier_service_channel), 204 | NotificationManager.IMPORTANCE_LOW, // Lowest level we are allowed to go 205 | ).apply { 206 | description = getString(R.string.notifier_service_channel_description) 207 | setShowBadge(false) 208 | } 209 | 210 | (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel( 211 | notifierServiceChannel 212 | ) 213 | 214 | return channelId 215 | } 216 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/anissan/battarang/utils/Ulog.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * ulog: simple, fast, and efficient logging facade for Kotlin 3 | * Source: https://github.com/kdrag0n/ulog 4 | * Compatibility Date: Aug 2, 2022 5 | * 6 | * - Fast: lazy message evaluation, no stack traces used for automatic tags (like square/logcat) 7 | * - Extensible with backends like Timber, but with a simpler API 8 | * - Easier to specify explicit tags than Timber 9 | * - Debug and verbose logs optimized out at compile time 10 | * (completely removed from release builds) 11 | * 12 | * Licensed under the MIT License (MIT) 13 | * 14 | * Copyright (c) 2022 Danny Lin 15 | * 16 | * Permission is hereby granted, free of charge, to any person obtaining a copy 17 | * of this software and associated documentation files (the "Software"), to deal 18 | * in the Software without restriction, including without limitation the rights 19 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 20 | * copies of the Software, and to permit persons to whom the Software is 21 | * furnished to do so, subject to the following conditions: 22 | * 23 | * The above copyright notice and this permission notice shall be included in all 24 | * copies or substantial portions of the Software. 25 | * 26 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 27 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 28 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE 29 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 30 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 31 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 32 | * SOFTWARE. 33 | */ 34 | 35 | @file:Suppress("FunctionName", "Unused") 36 | 37 | package com.anissan.battarang.utils 38 | 39 | import android.util.Log 40 | import com.anissan.battarang.BuildConfig 41 | import org.jetbrains.annotations.ApiStatus.Internal 42 | import java.io.PrintWriter 43 | import java.io.StringWriter 44 | 45 | // APIs must be public for inline calls 46 | object Ulog { 47 | // perf 48 | @Internal 49 | @JvmStatic 50 | var backends = emptyArray() 51 | 52 | const val LOG_VERBOSE = true 53 | 54 | @JvmStatic 55 | val LOG_DEBUG = BuildConfig.DEBUG 56 | 57 | fun installBackend(backend: LogBackend) { 58 | backends += backend 59 | } 60 | 61 | @JvmStatic 62 | @Internal 63 | fun _formatException(e: Throwable) = e.let { 64 | val sw = StringWriter(256) 65 | val pw = PrintWriter(sw, false) 66 | e.printStackTrace(pw) 67 | pw.flush() 68 | '\n' + sw.toString() 69 | } 70 | 71 | @JvmStatic 72 | @Internal 73 | fun _print(tag: String, priority: Int, msg: String, exception: Throwable?) { 74 | backends.forEach { 75 | it.print(tag, priority, msg, exception) 76 | } 77 | } 78 | 79 | // Change this if you have more stringent min API requirements 80 | @JvmStatic 81 | @Internal 82 | fun _getDefaultTag(): String = BuildConfig.APPLICATION_ID 83 | } 84 | 85 | interface LogBackend { 86 | fun print(tag: String, priority: Int, message: String, exception: Throwable?) 87 | } 88 | 89 | class SystemLogBackend : LogBackend { 90 | override fun print(tag: String, priority: Int, message: String, exception: Throwable?) { 91 | val finalMsg = if (exception != null) message + Ulog._formatException(exception) else message 92 | Log.println(priority, tag, finalMsg) 93 | } 94 | } 95 | 96 | // Must be public for inline calls 97 | @Internal 98 | fun Any.__className_ulog_internal(): String { 99 | val clazz = this::class.java 100 | val name = clazz.simpleName 101 | // Slow path for anonymous classes 102 | return name.ifEmpty { clazz.name.split('.').last() } 103 | } 104 | 105 | /* 106 | * Generic (all levels) 107 | * Can't be optimized much as this needs to support all priorities. 108 | */ 109 | inline fun log( 110 | tag: String? = null, 111 | priority: Int = Log.DEBUG, 112 | exception: Throwable? = null, 113 | message: () -> String = { "" }, 114 | ) { 115 | val msg = message() 116 | val finalTag = tag ?: Ulog._getDefaultTag() 117 | Ulog._print(finalTag, priority, msg, exception) 118 | } 119 | 120 | inline fun log( 121 | exception: Throwable, 122 | tag: String? = null, 123 | priority: Int = Log.DEBUG, 124 | message: () -> String = { "" }, 125 | ) = log(tag, priority, exception, message) 126 | 127 | inline fun log( 128 | priority: Int, 129 | tag: String? = null, 130 | exception: Throwable? = null, 131 | message: () -> String = { "" }, 132 | ) = log(tag, priority, exception, message) 133 | 134 | inline fun Any.log( 135 | tag: String? = null, 136 | priority: Int = Log.DEBUG, 137 | exception: Throwable? = null, 138 | message: () -> String = { "" }, 139 | ) { 140 | val msg = message() 141 | val finalTag = tag ?: __className_ulog_internal() 142 | Ulog._print(finalTag, priority, msg, exception) 143 | } 144 | 145 | inline fun Any.log( 146 | exception: Throwable, 147 | tag: String? = null, 148 | priority: Int = Log.DEBUG, 149 | message: () -> String = { "" }, 150 | ) = log(tag, priority, exception, message) 151 | 152 | inline fun Any.log( 153 | priority: Int, 154 | tag: String? = null, 155 | exception: Throwable? = null, 156 | message: () -> String = { "" }, 157 | ) = log(tag, priority, exception, message) 158 | 159 | /* 160 | * Verbose 161 | * Optimized out at compile time when possible. 162 | */ 163 | inline fun logV( 164 | tag: String? = null, 165 | exception: Throwable? = null, 166 | message: () -> String = { "" }, 167 | ) { 168 | if (!Ulog.LOG_VERBOSE) return 169 | 170 | val msg = message() 171 | val finalTag = tag ?: Ulog._getDefaultTag() 172 | Ulog._print(finalTag, Log.VERBOSE, msg, exception) 173 | } 174 | 175 | inline fun logV( 176 | exception: Throwable, 177 | tag: String? = null, 178 | message: () -> String = { "" }, 179 | ) = logV(tag, exception, message) 180 | 181 | inline fun Any.logV( 182 | tag: String? = null, 183 | exception: Throwable? = null, 184 | message: () -> String = { "" }, 185 | ) { 186 | if (!Ulog.LOG_VERBOSE) return 187 | 188 | val msg = message() 189 | val finalTag = tag ?: __className_ulog_internal() 190 | Ulog._print(finalTag, Log.VERBOSE, msg, exception) 191 | } 192 | 193 | inline fun Any.logV( 194 | exception: Throwable, 195 | tag: String? = null, 196 | message: () -> String = { "" }, 197 | ) = logV(tag, exception, message) 198 | 199 | /* 200 | * Debug 201 | */ 202 | inline fun logD( 203 | tag: String? = null, 204 | exception: Throwable? = null, 205 | message: () -> String = { "" }, 206 | ) { 207 | if (!Ulog.LOG_DEBUG) return 208 | 209 | val msg = message() 210 | val finalTag = tag ?: Ulog._getDefaultTag() 211 | Ulog._print(finalTag, Log.DEBUG, msg, exception) 212 | } 213 | 214 | inline fun logD( 215 | exception: Throwable, 216 | tag: String? = null, 217 | message: () -> String = { "" }, 218 | ) = logD(tag, exception, message) 219 | 220 | inline fun Any.logD( 221 | tag: String? = null, 222 | exception: Throwable? = null, 223 | message: () -> String = { "" }, 224 | ) { 225 | if (!Ulog.LOG_DEBUG) return 226 | 227 | val msg = message() 228 | val finalTag = tag ?: __className_ulog_internal() 229 | Ulog._print(finalTag, Log.DEBUG, msg, exception) 230 | } 231 | 232 | inline fun Any.logD( 233 | exception: Throwable, 234 | tag: String? = null, 235 | message: () -> String = { "" }, 236 | ) = logD(tag, exception, message) 237 | 238 | /* 239 | * Other levels 240 | * (no special optimizations) 241 | */ 242 | inline fun logI( 243 | tag: String? = null, 244 | exception: Throwable? = null, 245 | message: () -> String = { "" }, 246 | ) = log(tag, Log.INFO, exception, message) 247 | 248 | inline fun logI( 249 | exception: Throwable, 250 | tag: String? = null, 251 | message: () -> String = { "" }, 252 | ) = log(tag, Log.INFO, exception, message) 253 | 254 | inline fun Any.logI( 255 | tag: String? = null, 256 | exception: Throwable? = null, 257 | message: () -> String = { "" }, 258 | ) = log(tag, Log.INFO, exception, message) 259 | 260 | inline fun Any.logI( 261 | exception: Throwable, 262 | tag: String? = null, 263 | message: () -> String = { "" }, 264 | ) = log(tag, Log.INFO, exception, message) 265 | 266 | inline fun logW( 267 | exception: Throwable? = null, 268 | tag: String? = null, 269 | message: () -> String = { "" }, 270 | ) = log(tag, Log.WARN, exception, message) 271 | 272 | inline fun logW( 273 | tag: String, 274 | exception: Throwable? = null, 275 | message: () -> String = { "" }, 276 | ) = log(tag, Log.WARN, exception, message) 277 | 278 | inline fun Any.logW( 279 | exception: Throwable? = null, 280 | tag: String? = null, 281 | message: () -> String = { "" }, 282 | ) = log(tag, Log.WARN, exception, message) 283 | 284 | inline fun Any.logW( 285 | tag: String, 286 | exception: Throwable? = null, 287 | message: () -> String = { "" }, 288 | ) = log(tag, Log.WARN, exception, message) 289 | 290 | inline fun logE( 291 | exception: Throwable? = null, 292 | tag: String? = null, 293 | message: () -> String = { "" }, 294 | ) = log(tag, Log.ERROR, exception, message) 295 | 296 | inline fun logE( 297 | tag: String, 298 | exception: Throwable? = null, 299 | message: () -> String = { "" }, 300 | ) = log(tag, Log.ERROR, exception, message) 301 | 302 | inline fun Any.logE( 303 | exception: Throwable? = null, 304 | tag: String? = null, 305 | message: () -> String = { "" }, 306 | ) = log(tag, Log.ERROR, exception, message) 307 | 308 | inline fun Any.logE( 309 | tag: String, 310 | exception: Throwable? = null, 311 | message: () -> String = { "" }, 312 | ) = log(tag, Log.ERROR, exception, message) 313 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /app/src/main/res/layout/tab_permission.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 20 | 21 | 22 | 36 | 37 | 44 | 45 | 53 | 54 | 62 | 63 | 71 | 72 | 73 | 82 | 83 | 84 | 85 | 86 | 99 | 100 | 107 | 108 | 116 | 117 | 125 | 126 | 134 | 135 | 136 | 144 | 145 | 146 | 147 | 148 | 162 | 163 | 170 | 171 | 179 | 180 | 188 | 189 | 197 | 198 | 199 | 206 | 207 | 208 | 209 | 215 | 216 | 217 | 226 | 227 | 235 | 236 | 244 | 245 | 246 | 247 | --------------------------------------------------------------------------------