├── .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 |
4 |
5 |
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 |
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 |
4 |
5 |
6 |
7 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/tab_tutorial.xml:
--------------------------------------------------------------------------------
1 |
2 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_external_link.xml:
--------------------------------------------------------------------------------
1 |
8 |
12 |
13 |
--------------------------------------------------------------------------------
/local.example.properties:
--------------------------------------------------------------------------------
1 | # suppress inspection "UnusedProperty" for whole file
2 | # .properties file format: https://en.wikipedia.org/wiki/.properties
3 | # Location of the SDK. This is only used by Gradle.
4 | sdk.dir=
5 | # BuildConfigs
6 | RECEIVER_API_URL=https://
7 | RECEIVER_WEBSITE=https://
8 | RECEIVER_WEBSITE_SHORT_LINK=https://
9 | TELEGRAM_BOT_URL=tg://resolve?domain=
10 | GITHUB_URL=https://github.com//
11 | ISSUES_URL=https://github.com///issues
12 | PLAY_STORE_LINK=https://play.google.com/store/apps/details?id=
13 | AUTHOR_WEBSITE=https://
14 | AUTHOR_TWITTER=https://twitter.com/
15 | AUTHOR_LINKEDIN=https://www.linkedin.com/in/
16 | AUTHOR_OTHER_PROJECTS=https://
17 | PRIVACY_POLICY_URL=https://
18 | TOS_URL=https://
19 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_play_store.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_linkedin.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_send.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_heart.xml:
--------------------------------------------------------------------------------
1 |
8 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_telegram.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notification_service.xml:
--------------------------------------------------------------------------------
1 |
7 |
13 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/notifierServiceToggle.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.widget.CompoundButton
4 | import com.anissan.battarang.ui.MainActivity
5 | import dev.chrisbanes.insetter.applyInsetter
6 |
7 | fun MainActivity.setupNotifierServiceToggle() {
8 | binding.nestedScrollView.applyInsetter {
9 | type(navigationBars = true) {
10 | margin(horizontal = true)
11 | }
12 | }
13 |
14 | binding.contentLayout.applyInsetter {
15 | type(navigationBars = true) {
16 | margin(vertical = true)
17 | }
18 | }
19 |
20 | binding.notifierServiceCard.apply {
21 | applyInsetter {
22 | type(navigationBars = true) {
23 | padding(horizontal = true)
24 | }
25 | }
26 |
27 | setOnClickListener {
28 | if (paired) {
29 | binding.notifierServiceSwitch.performClick()
30 | }
31 | }
32 | }
33 |
34 | binding.notifierServiceSwitch.setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
35 | localKvStore.isMonitoringServiceEnabled = isChecked
36 | }
37 |
38 | refreshServiceState()
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/background/receivers/BootEventReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.background.receivers
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.anissan.battarang.background.services.BroadcastReceiverRegistererService
7 | import com.anissan.battarang.utils.logE
8 | import com.anissan.battarang.utils.logV
9 |
10 | /**
11 | * Receives an event after boot and app update.
12 | * */
13 | class BootEventReceiver : BroadcastReceiver() {
14 |
15 | override fun onReceive(context: Context?, intent: Intent?) {
16 | if (context == null) return
17 |
18 | val action: String = intent?.action ?: return
19 | logV { """Received the Intent Action: "$action"""" }
20 |
21 | when (action) {
22 | "android.intent.action.BOOT_COMPLETED",
23 | "android.intent.action.QUICKBOOT_POWERON",
24 | "com.htc.intent.action.QUICKBOOT_POWERON",
25 | "android.intent.action.MY_PACKAGE_REPLACED",
26 | -> BroadcastReceiverRegistererService.start(context)
27 |
28 | else -> logE { "$action is not a supported action by this receiver" }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/background/receivers/ChargerConnectionReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.background.receivers
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers
7 | import com.anissan.battarang.utils.logE
8 | import com.anissan.battarang.utils.logV
9 | import org.koin.core.component.KoinComponent
10 | import org.koin.core.component.inject
11 |
12 | class ChargerConnectionReceiver : BroadcastReceiver(), KoinComponent {
13 | private val broadcastedEventHandlers: BroadcastedEventHandlers by inject()
14 |
15 | override fun onReceive(context: Context?, intent: Intent?) {
16 | val action: String = intent?.action ?: return
17 | logV { """Received the Intent Action: "$action"""" }
18 |
19 | when (action) {
20 | Intent.ACTION_POWER_CONNECTED -> broadcastedEventHandlers.startBatteryLevelPollingAlarm()
21 | Intent.ACTION_POWER_DISCONNECTED -> broadcastedEventHandlers.stopBatteryLevelPollingAlarm()
22 | else -> logE { "$action is not a supported action by this receiver" }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/logo.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
17 |
23 |
24 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_copyright.xml:
--------------------------------------------------------------------------------
1 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_twitter.xml:
--------------------------------------------------------------------------------
1 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_visibility_off.xml:
--------------------------------------------------------------------------------
1 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/background/receivers/ChargerConnectedReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.background.receivers
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import androidx.core.content.ContextCompat
8 | import com.anissan.battarang.utils.logE
9 | import com.anissan.battarang.utils.logV
10 | import org.koin.core.component.KoinComponent
11 | import org.koin.core.component.inject
12 |
13 | class ChargerConnectedReceiver : BroadcastReceiver(), KoinComponent {
14 | private val batteryLowReceiver: BatteryLowReceiver by inject()
15 |
16 | override fun onReceive(context: Context?, intent: Intent?) {
17 | if (context == null) return
18 |
19 | val action: String = intent?.action ?: return
20 | logV { """Received the Intent Action: "$action"""" }
21 |
22 | when (action) {
23 | Intent.ACTION_POWER_CONNECTED -> {
24 | context.unregisterReceiver(this)
25 |
26 | ContextCompat.registerReceiver(
27 | context,
28 | batteryLowReceiver,
29 | IntentFilter(Intent.ACTION_BATTERY_LOW),
30 | ContextCompat.RECEIVER_NOT_EXPORTED,
31 | )
32 | }
33 |
34 | else -> logE { "$action is not a supported action by this receiver" }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_github.xml:
--------------------------------------------------------------------------------
1 |
8 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/sheet_optimization_remover.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
16 |
17 |
24 |
25 |
31 |
32 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/optimization/OptimizationRequestDialog.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views.optimization
2 |
3 | import android.os.Bundle
4 | import androidx.appcompat.app.AlertDialog
5 | import com.anissan.battarang.R
6 | import com.anissan.battarang.ui.MainActivity
7 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
8 |
9 | private lateinit var optimizationRequestDialog: AlertDialog
10 |
11 | fun MainActivity.showOptimizationRequestDialog() {
12 | optimizationRequestDialog = MaterialAlertDialogBuilder(this, R.style.CenteredDialog)
13 | .setCancelable(true)
14 | .setIcon(R.drawable.ic_heart)
15 | .setTitle(R.string.optimization_exemption_title)
16 | .setMessage(R.string.optimization_exemption_dialog)
17 | .setPositiveButton(R.string.optimization_exemption_button) { _, _ ->
18 | OptimizationRemoverSheet.show(supportFragmentManager)
19 | }
20 | .show()
21 | }
22 |
23 | private const val OPTIMIZATION_REQUEST_DIALOG = "optimization_request_dialog"
24 |
25 | fun saveOptimizationRequestDialogState(outState: Bundle) {
26 | outState.putBoolean(
27 | OPTIMIZATION_REQUEST_DIALOG,
28 | if (::optimizationRequestDialog.isInitialized) optimizationRequestDialog.isShowing else false,
29 | )
30 | }
31 |
32 | fun MainActivity.restoreOptimizationRequestDialogState(savedInstanceState: Bundle) {
33 | if (savedInstanceState.getBoolean(OPTIMIZATION_REQUEST_DIALOG)) showOptimizationRequestDialog()
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_phone.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
13 |
17 |
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/appBar.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import com.anissan.battarang.R
4 | import com.anissan.battarang.ui.MainActivity
5 | import com.anissan.battarang.ui.views.optimization.OptimizationRemoverSheet
6 | import com.google.android.material.elevation.SurfaceColors
7 | import dev.chrisbanes.insetter.applyInsetter
8 |
9 | fun MainActivity.setupAppBar() {
10 | binding.collapsingToolbarLayout.applyInsetter {
11 | type(navigationBars = true, statusBars = true) {
12 | margin(horizontal = true)
13 | padding(vertical = true)
14 | }
15 | }
16 |
17 | /* Calculating the marginStart value for the Expanded Title to align it with the centered cards. */
18 |
19 | val screenWidth = resources.displayMetrics.widthPixels
20 | val cardMaxWidth = resources.getDimension(R.dimen.card_max_width).toInt()
21 | val expandedTitleMargin = binding.collapsingToolbarLayout.expandedTitleMarginStart
22 |
23 | if (screenWidth > cardMaxWidth) {
24 | binding.collapsingToolbarLayout.expandedTitleMarginStart =
25 | (screenWidth - cardMaxWidth + expandedTitleMargin) / 2
26 | }
27 |
28 | binding.collapsingToolbarLayout.setContentScrimColor(SurfaceColors.SURFACE_5.getColor(this))
29 |
30 | binding.materialToolbar.setOnMenuItemClickListener { menuItem ->
31 | when (menuItem.itemId) {
32 | R.id.remove_battery_restriction -> {
33 | OptimizationRemoverSheet.show(supportFragmentManager)
34 | true
35 | }
36 |
37 | else -> false
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/utils.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.content.Context
4 | import android.os.Build
5 | import android.provider.Settings
6 | import androidx.appcompat.app.AppCompatActivity
7 | import com.google.android.material.card.MaterialCardView
8 | import com.google.android.material.checkbox.MaterialCheckBox
9 | import com.google.android.material.elevation.SurfaceColors
10 |
11 | /**
12 | * Checkboxes has a ripple animation set only on its checkmark icon area, not the text beside it.
13 | * Setting a custom ripple on the whole checkbox doesn't look good if there's a margin applied because
14 | * the ripple won't reach the edges.
15 | *
16 | * The only workaround I've found is to use a full bleed wrapper card (which already has a nice
17 | * ripple effect built-in) and disabling the checkbox so that
18 | * the card receives the clicks which it then forwards it to the checkbox.
19 | */
20 | fun MaterialCheckBox.bindClicksFrom(card: MaterialCardView) {
21 | card.setOnClickListener { performClick() }
22 | }
23 |
24 | val AppCompatActivity.dynamicSurfaceColor: Int
25 | get() = SurfaceColors.SURFACE_2.getColor(this)
26 |
27 | val Context.defaultDeviceName: String
28 | get() {
29 | val deviceName: String? = if (Build.VERSION.SDK_INT >= 25) {
30 | Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME)
31 | } else {
32 | Settings.Secure.getString(contentResolver, "bluetooth_name")
33 | }
34 |
35 | return (deviceName ?: Build.MODEL).trim().ifBlank { "Unknown" }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/background/receivers/BatteryLevelCheckerAlarmReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.background.receivers
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers
7 | import com.anissan.battarang.utils.logE
8 | import com.anissan.battarang.utils.logV
9 | import org.koin.core.component.KoinComponent
10 | import org.koin.core.component.inject
11 |
12 | /**
13 | * Mainly for managing the alarm event that checks current battery level percentage.
14 | * There's a method to directly listens for battery level change sent by the system, but it gets
15 | * called too frequently. It is more efficient to check the percentage periodically as this alarm
16 | * starts only when device is charging.
17 | */
18 | class BatteryLevelCheckerAlarmReceiver : BroadcastReceiver(), KoinComponent {
19 | private val broadcastedEventHandlers: BroadcastedEventHandlers by inject()
20 |
21 | override fun onReceive(context: Context?, intent: Intent?) {
22 | val action: String = intent?.action ?: return
23 | logV { """Received the Intent Action: "$action"""" }
24 |
25 | when (action) {
26 | ACTION_CHECK_BATTERY_LEVEL -> broadcastedEventHandlers.notifyIfMaxLevelReached()
27 | else -> logE { "$action is not a supported action by this receiver" }
28 | }
29 | }
30 |
31 | companion object {
32 | val ACTION_CHECK_BATTERY_LEVEL =
33 | "${BatteryLevelCheckerAlarmReceiver::class.java.name}.check_battery_level"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/deviceNameInput.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.view.View
4 | import android.view.inputmethod.EditorInfo
5 | import androidx.core.view.WindowCompat
6 | import androidx.core.view.WindowInsetsCompat
7 | import com.anissan.battarang.data.LocalKvStore
8 | import com.anissan.battarang.ui.MainActivity
9 | import com.google.android.material.textfield.TextInputEditText
10 |
11 | fun MainActivity.setupDeviceNameInput() {
12 | binding.deviceNameLayout.apply {
13 | boxBackgroundColor = dynamicSurfaceColor
14 |
15 | isEndIconVisible = false
16 |
17 | setEndIconOnClickListener {
18 | binding.deviceNameInput.clearFocus()
19 | }
20 | }
21 |
22 | binding.deviceNameInput.apply {
23 | setText(localKvStore.deviceName)
24 |
25 | setOnEditorActionListener { _, actionId: Int, _ ->
26 | if (actionId == EditorInfo.IME_ACTION_DONE) {
27 | clearFocus()
28 | true
29 | } else {
30 | false
31 | }
32 | }
33 |
34 | setOnFocusChangeListener { _: View?, hasFocus: Boolean ->
35 | binding.deviceNameLayout.isEndIconVisible = hasFocus
36 |
37 | if (!hasFocus) {
38 | saveEditedText(localKvStore)
39 |
40 | // Hiding the keyboard
41 | WindowCompat.getInsetsController(window, this).hide(WindowInsetsCompat.Type.ime())
42 | }
43 | }
44 | }
45 | }
46 |
47 | fun TextInputEditText.saveEditedText(localKvStore: LocalKvStore) {
48 | localKvStore.deviceName =
49 | text.toString().ifBlank { context.defaultDeviceName }.apply { setText(this) }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_switch_switch.xml:
--------------------------------------------------------------------------------
1 |
8 |
14 |
19 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/lowLevelCheckbox.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.graphics.Typeface
4 | import android.text.Spannable
5 | import android.text.SpannableString
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 |
13 | fun MainActivity.setupLowBatteryLevelCheckbox() {
14 | binding.lowBatteryLevelCheckbox.run {
15 | isChecked = localKvStore.isLowBatteryNotificationEnabled
16 |
17 | // Formatting the "LOW" portion as bold with color
18 | text = SpannableString(getString(R.string.battery_is_low_template)).apply {
19 | val wEnd = length // Battery is LOW|
20 | val lStart = wEnd - 3 // Battery is |LOW
21 |
22 | setSpan(
23 | StyleSpan(Typeface.BOLD),
24 | lStart,
25 | wEnd,
26 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
27 | )
28 |
29 | val textColor = MaterialColors.getColor(
30 | this@run,
31 | com.google.android.material.R.attr.colorOnSurfaceVariant,
32 | )
33 |
34 | setSpan(
35 | ForegroundColorSpan(textColor),
36 | lStart,
37 | wEnd,
38 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
39 | )
40 | }
41 |
42 | bindClicksFrom(binding.lowBatteryLevelCard)
43 |
44 | setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
45 | localKvStore.isLowBatteryNotificationEnabled = isChecked
46 | }
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/optimization/tabs/TutorialTabViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views.optimization.tabs
2 |
3 | import android.view.View
4 | import androidx.core.content.ContextCompat
5 | import androidx.recyclerview.widget.RecyclerView
6 | import com.anissan.battarang.R
7 | import com.google.android.material.appbar.AppBarLayout
8 | import com.google.android.material.appbar.CollapsingToolbarLayout
9 | import com.google.android.material.elevation.SurfaceColors
10 | import dev.chrisbanes.insetter.applyInsetter
11 | import dev.doubledot.doki.views.DokiContentView
12 |
13 | class TutorialTabViewHolder(private val dokiView: DokiContentView) :
14 | RecyclerView.ViewHolder(dokiView) {
15 |
16 | companion object {
17 | const val TAB_TITLE = "Tutorial"
18 | }
19 |
20 | init {
21 | dokiView.run {
22 | setButtonsVisibility(false)
23 |
24 | val appbar = findViewById(dev.doubledot.doki.R.id.appbar)
25 |
26 | (appbar.getChildAt(0) as CollapsingToolbarLayout).setContentScrimColor(
27 | ContextCompat.getColor(context, android.R.color.transparent)
28 | )
29 |
30 | headerBackgroundColor = SurfaceColors.SURFACE_1.getColor(context)
31 |
32 | findViewById(dev.doubledot.doki.R.id.divider3).visibility = View.GONE
33 |
34 | findViewById(dev.doubledot.doki.R.id.footer).applyInsetter {
35 | type(navigationBars = true) {
36 | margin(vertical = true)
37 | }
38 | }
39 | }
40 | }
41 |
42 | fun requestApiData() {
43 | dokiView.loadContent(appName = dokiView.context.getString(R.string.app_name))
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/skipWhileDisplayOnCheckbox.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.graphics.Typeface
4 | import android.text.Spannable
5 | import android.text.SpannableString
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 |
13 | fun MainActivity.setupSkipIfDisplayOnToggleCheckbox() {
14 | binding.skipWhileDisplayOnCheckbox.run {
15 | isChecked = localKvStore.isSkipWhileDisplayOnEnabled
16 |
17 | // Formatting the "ON" portion bold with color
18 | text = SpannableString(getString(R.string.skip_if_display_on_template)).apply {
19 | val nEnd = length // Skip while display is ON|
20 | val oStart = nEnd - 2 // Skip while display is |ON
21 |
22 | setSpan(
23 | StyleSpan(Typeface.BOLD),
24 | oStart,
25 | nEnd,
26 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
27 | )
28 |
29 | val textColor = MaterialColors.getColor(
30 | this@run,
31 | com.google.android.material.R.attr.colorOnSurfaceVariant,
32 | )
33 |
34 | setSpan(
35 | ForegroundColorSpan(textColor),
36 | oStart,
37 | nEnd,
38 | Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
39 | )
40 | }
41 |
42 | bindClicksFrom(binding.skipWhileDisplayOnCard)
43 |
44 | setOnCheckedChangeListener { _: CompoundButton, isChecked: Boolean ->
45 | localKvStore.isSkipWhileDisplayOnEnabled = isChecked
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/optimization/tabs/TabAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views.optimization.tabs
2 |
3 | import android.content.Intent
4 | import android.view.LayoutInflater
5 | import android.view.ViewGroup
6 | import androidx.activity.result.ActivityResultLauncher
7 | import androidx.recyclerview.widget.RecyclerView
8 | import com.anissan.battarang.databinding.TabPermissionBinding
9 | import com.anissan.battarang.databinding.TabTutorialBinding
10 |
11 | class TabAdapter(private val activityResultLauncher: ActivityResultLauncher) :
12 | RecyclerView.Adapter() {
13 |
14 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
15 | return when (viewType) {
16 | 0 -> PermissionTabViewHolder(
17 | TabPermissionBinding.inflate(
18 | LayoutInflater.from(parent.context),
19 | parent,
20 | false,
21 | ), activityResultLauncher
22 | )
23 |
24 | 1 -> TutorialTabViewHolder(
25 | TabTutorialBinding.inflate(
26 | LayoutInflater.from(parent.context),
27 | parent,
28 | false,
29 | ).root
30 | )
31 |
32 | else -> throw IllegalStateException("Currently supports two types of ViewHolder for two tabs.")
33 | }
34 | }
35 |
36 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
37 | when (holder) {
38 | is PermissionTabViewHolder -> holder.refreshSwitchStates()
39 | is TutorialTabViewHolder -> holder.requestApiData()
40 | }
41 | }
42 |
43 | override fun getItemCount(): Int = 2
44 |
45 | override fun getItemViewType(position: Int): Int = position
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/background/receivers/BatteryLowReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.background.receivers
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import androidx.core.content.ContextCompat
8 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers
9 | import com.anissan.battarang.utils.logE
10 | import com.anissan.battarang.utils.logV
11 | import org.koin.core.component.KoinComponent
12 | import org.koin.core.component.inject
13 |
14 | class BatteryLowReceiver : BroadcastReceiver(), KoinComponent {
15 | private val broadcastedEventHandlers: BroadcastedEventHandlers by inject()
16 | private val chargerConnectedReceiver: ChargerConnectedReceiver by inject()
17 |
18 | override fun onReceive(context: Context?, intent: Intent?) {
19 | if (context == null) return
20 |
21 | val action: String = intent?.action ?: return
22 | logV { """Received the Intent Action: "$action"""" }
23 |
24 | when (action) {
25 | Intent.ACTION_BATTERY_LOW -> {
26 | // Instead of triggering this event only once, some OEMs spam this constantly.
27 | // So I'm unregistering this until the next charger connection.
28 | context.unregisterReceiver(this)
29 |
30 | ContextCompat.registerReceiver(
31 | context,
32 | chargerConnectedReceiver,
33 | IntentFilter(Intent.ACTION_POWER_CONNECTED),
34 | ContextCompat.RECEIVER_NOT_EXPORTED,
35 | )
36 |
37 | broadcastedEventHandlers.notifyBatteryIsLow()
38 | }
39 |
40 | else -> logE { "$action is not a supported action by this receiver" }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/pairing/qrScanner.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views.pairing
2 |
3 | import androidx.activity.result.ActivityResultLauncher
4 | import com.anissan.battarang.R
5 | import com.anissan.battarang.ui.MainActivity
6 | import com.anissan.battarang.utils.logE
7 | import com.anissan.battarang.utils.logV
8 | import io.github.g00fy2.quickie.QRResult
9 | import io.github.g00fy2.quickie.ScanCustomCode
10 | import io.github.g00fy2.quickie.config.BarcodeFormat
11 | import io.github.g00fy2.quickie.config.ScannerConfig
12 |
13 | private lateinit var qrCodeScanner: ActivityResultLauncher
14 | private lateinit var onQrSuccess: (result: String) -> Unit
15 |
16 | fun MainActivity.registerQrScanner() {
17 | qrCodeScanner =
18 | registerForActivityResult(ScanCustomCode()) { scanResult: QRResult ->
19 | when (scanResult) {
20 | QRResult.QRUserCanceled -> logV { "User went back without scanning a QR code" }
21 | QRResult.QRMissingPermission -> showSnackbar(R.string.camera_permission_missing)
22 |
23 | is QRResult.QRError -> {
24 | logE(scanResult.exception)
25 | showSnackbar(R.string.camera_unavailable)
26 | }
27 |
28 | is QRResult.QRSuccess -> onQrSuccess(scanResult.content.rawValue!!)
29 | }
30 | }
31 | }
32 |
33 | fun launchQrScanner(onSuccess: (result: String) -> Unit) {
34 | val qrScannerConfig = ScannerConfig.build {
35 | setBarcodeFormats(listOf(BarcodeFormat.FORMAT_QR_CODE))
36 | setOverlayDrawableRes(R.drawable.ic_qr_code)
37 | setOverlayStringRes(R.string.quickie_overlay_string)
38 | setHapticSuccessFeedback(true)
39 | }
40 |
41 | onQrSuccess = onSuccess
42 |
43 | if (::qrCodeScanner.isInitialized) return qrCodeScanner.launch(qrScannerConfig)
44 | else throw Exception("Make sure to `registerQrScanner` on onCreate before launching the scanner.")
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/ui/views/pairReceiverFab.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang.ui.views
2 |
3 | import android.Manifest
4 | import android.content.pm.PackageManager
5 | import android.os.Build
6 | import android.view.View
7 | import androidx.core.content.ContextCompat
8 | import androidx.core.view.ViewCompat
9 | import androidx.core.view.WindowInsetsCompat
10 | import androidx.core.widget.NestedScrollView
11 | import com.anissan.battarang.ui.MainActivity
12 | import com.anissan.battarang.ui.views.pairing.showPairingDialog
13 | import com.anissan.battarang.ui.views.permission.registerForRequestingPermission
14 | import com.anissan.battarang.ui.views.permission.requestNotificationPermission
15 | import dev.chrisbanes.insetter.applyInsetter
16 |
17 | fun MainActivity.setupPairReceiverFab() {
18 | binding.pairReceiverFab.run {
19 | applyInsetter {
20 | type(navigationBars = true) {
21 | margin(horizontal = true, vertical = true)
22 | }
23 | }
24 |
25 | if (paired) hide() else show()
26 |
27 | if (Build.VERSION.SDK_INT >= 33 && (ContextCompat.checkSelfPermission(
28 | this@setupPairReceiverFab,
29 | Manifest.permission.POST_NOTIFICATIONS
30 | ) != PackageManager.PERMISSION_GRANTED)
31 | ) {
32 | val requestPermissionLauncher = registerForRequestingPermission()
33 | setOnClickListener { requestNotificationPermission(requestPermissionLauncher) }
34 | } else setOnClickListener { showPairingDialog() }
35 | }
36 |
37 | binding.nestedScrollView.setOnScrollChangeListener(
38 | NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
39 | binding.pairReceiverFab.run {
40 | if (scrollY > oldScrollY) {
41 | if (isExtended) shrink()
42 | } else if (isExtended.not()) extend()
43 | }
44 | }
45 | )
46 |
47 | // Hiding the FAB when keyboard shows up, otherwise it can overlap with the input field on a smaller device.
48 | ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, insets: WindowInsetsCompat ->
49 | if (!paired) {
50 | val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
51 | if (isKeyboardVisible) binding.pairReceiverFab.hide() else binding.pairReceiverFab.show()
52 | }
53 |
54 | insets
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/com/anissan/battarang/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package com.anissan.battarang
2 |
3 | import android.app.Application
4 | import android.os.PowerManager
5 | import androidx.appcompat.app.AppCompatDelegate
6 | import com.anissan.battarang.background.receivers.BatteryLevelCheckerAlarmReceiver
7 | import com.anissan.battarang.background.receivers.BatteryLowReceiver
8 | import com.anissan.battarang.background.receivers.ChargerConnectedReceiver
9 | import com.anissan.battarang.background.receivers.ChargerConnectionReceiver
10 | import com.anissan.battarang.background.receivers.handlers.BroadcastedEventHandlers
11 | import com.anissan.battarang.data.LocalKvStore
12 | import com.anissan.battarang.network.ReceiverApiClient
13 | import com.anissan.battarang.utils.SystemLogBackend
14 | import com.anissan.battarang.utils.Ulog
15 | import com.google.android.material.color.DynamicColors
16 | import okhttp3.OkHttpClient
17 | import org.koin.android.ext.koin.androidContext
18 | import org.koin.android.ext.koin.androidLogger
19 | import org.koin.core.context.startKoin
20 | import org.koin.core.module.dsl.singleOf
21 | import org.koin.dsl.module
22 |
23 | class MainApplication : Application() {
24 | override fun onCreate() {
25 | super.onCreate()
26 |
27 | if (DynamicColors.isDynamicColorAvailable().not()) {
28 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
29 | }
30 |
31 | // Enables app wide dynamic theme on Android 12+ using the Material library.
32 | DynamicColors.applyToActivitiesIfAvailable(this)
33 |
34 | // Setup "ulog" for debug builds, skip in release builds.
35 | Ulog.installBackend(SystemLogBackend())
36 |
37 | /* Dependency Injection */
38 | startKoin {
39 | androidLogger()
40 | androidContext(this@MainApplication)
41 |
42 | modules(module {
43 | single { LocalKvStore(androidContext()) }
44 |
45 | single {
46 | ReceiverApiClient(
47 | powerManager = androidContext().getSystemService(POWER_SERVICE) as PowerManager,
48 | okHttpClient = OkHttpClient(),
49 | localKvStore = get(),
50 | )
51 | }
52 |
53 | singleOf(::BatteryLowReceiver)
54 | singleOf(::ChargerConnectedReceiver)
55 | singleOf(::ChargerConnectionReceiver)
56 | singleOf(::BatteryLevelCheckerAlarmReceiver)
57 | singleOf(::BroadcastedEventHandlers)
58 | })
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | #
8 | # Specifies the JVM arguments used for the daemon process.
9 | # Without this flag, every build is taking over 30s on my Windows machine
10 | org.gradle.vfs.watch=true
11 | # https://developer.android.com/build/optimize-your-build#increase-the-jvm-heap-size
12 | org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
13 | #
14 | # https://docs.gradle.org/current/userguide/build_cache.html#sec:build_cache_enable
15 | org.gradle.caching=true
16 | #
17 | # Use this flag carefully, in case some of the plugins are not fully compatible.
18 | org.gradle.configuration-cache=true
19 | org.gradle.configuration-cache.problems=warn
20 | #
21 | # When configured, Gradle will run in incubating parallel mode.
22 | # This option should only be used with decoupled projects. More details, visit
23 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
24 | org.gradle.parallel=true
25 | #
26 | # AndroidX package structure to make it clearer which packages are bundled with the
27 | # Android operating system, and which are packaged with your app's APK
28 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
29 | android.useAndroidX=true
30 | #
31 | # Automatically convert third-party libraries to use AndroidX.
32 | # Remove if possible: https://github.com/dipien/bye-bye-jetifier
33 | # "Doki" still has a legacy dependancy
34 | android.enableJetifier=true
35 | #
36 | # Kotlin code style for this project: "official" or "obsolete":
37 | kotlin.code.style=official
38 | #
39 | # Enables namespacing of each library's R class so that its R class includes only the
40 | # resources declared in the library itself and none from the library's dependencies,
41 | # thereby reducing the size of the R class for that library
42 | android.nonTransitiveRClass=true
43 | #
44 | # Generate BuildConfigs directly as a compiled class instead of Java
45 | android.enableBuildConfigAsBytecode=true
46 | #
47 | # Full mode deletes bunch of Doki's Retrofit and GSON classes which leads to Tutorial tab crashing
48 | android.enableR8.fullMode=false
49 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 |

5 |

6 |
7 |
8 |
9 |
10 | 🔔🔋 Sync battery notifications across devices 🪫🔔
11 |
12 | 
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 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
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 | xmlns:android
90 |
91 | ^$
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | xmlns:.*
101 |
102 | ^$
103 |
104 |
105 | BY_NAME
106 |
107 |
108 |
109 |
110 |
111 |
112 | .*:id
113 |
114 | http://schemas.android.com/apk/res/android
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | .*:name
124 |
125 | http://schemas.android.com/apk/res/android
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | name
135 |
136 | ^$
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | style
146 |
147 | ^$
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | .*
157 |
158 | ^$
159 |
160 |
161 | BY_NAME
162 |
163 |
164 |
165 |
166 |
167 |
168 | .*
169 |
170 | http://schemas.android.com/apk/res/android
171 |
172 |
173 | ANDROID_ATTRIBUTE_ORDER
174 |
175 |
176 |
177 |
178 |
179 |
180 | .*
181 |
182 | .*
183 |
184 |
185 | BY_NAME
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
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 |
--------------------------------------------------------------------------------