├── .gitignore
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── nll
│ │ └── store
│ │ ├── ApkAttachmentProvider.kt
│ │ ├── App.kt
│ │ ├── activityresult
│ │ └── InstallPermissionContract.kt
│ │ ├── api
│ │ ├── ApiClient.kt
│ │ ├── ApiException.kt
│ │ ├── HttpRequester.kt
│ │ ├── HttpTransport.kt
│ │ ├── NLLStoreApi.kt
│ │ ├── PackageReceiver.kt
│ │ ├── RetryStrategy.kt
│ │ ├── StoreApiManager.kt
│ │ └── Timeout.kt
│ │ ├── connectivity
│ │ ├── InternetStateMonitor.kt
│ │ ├── InternetStateProvider.kt
│ │ └── NetworkState.kt
│ │ ├── debug
│ │ ├── DebugLogActivity.kt
│ │ ├── DebugLogAttachmentProvider.java
│ │ ├── DebugLogService.kt
│ │ ├── DebugLogServiceCommand.kt
│ │ ├── DebugLogServiceMessage.kt
│ │ └── DebugNotification.kt
│ │ ├── installer
│ │ ├── ApkSource.kt
│ │ ├── AppInstallManager.kt
│ │ ├── FileApkSource.kt
│ │ ├── FileDownloader.kt
│ │ ├── HttpProvider.kt
│ │ ├── InstallationEventsReceiver.kt
│ │ ├── InstallationState.kt
│ │ ├── PackageInstallFailureCause.kt
│ │ ├── PackageInstallResult.kt
│ │ └── UriApkSource.kt
│ │ ├── log
│ │ └── CLog.kt
│ │ ├── model
│ │ ├── AppData.kt
│ │ ├── AppInstallState.kt
│ │ ├── LocalAppData.kt
│ │ ├── StoreAppData.kt
│ │ └── StoreConnectionState.kt
│ │ ├── ui
│ │ ├── AppListActivity.kt
│ │ ├── AppListViewHolder.kt
│ │ ├── AppsListAdapter.kt
│ │ ├── InputStream.kt
│ │ ├── InstallAppFragment.kt
│ │ └── SnackProvider.kt
│ │ ├── update
│ │ ├── PeriodicUpdateCheckWorker.kt
│ │ └── UpdateNotification.kt
│ │ └── utils
│ │ ├── ApiLevel.kt
│ │ ├── Context.kt
│ │ ├── CoroutineScopeFactory.kt
│ │ ├── File.kt
│ │ ├── Flow.kt
│ │ ├── Int.kt
│ │ ├── Intent.kt
│ │ ├── Long.kt
│ │ ├── PackageManager.kt
│ │ └── SingletonHolder.kt
│ └── res
│ ├── anim
│ ├── fullscreen_dialog_slide_in_bottom.xml
│ └── fullscreen_dialog_slide_out_bottom.xml
│ ├── drawable
│ ├── app_list_divider.xml
│ ├── crash_log_discard.xml
│ ├── crash_log_send.xml
│ ├── ic_arrow_back_24.xml
│ ├── ic_install.xml
│ ├── ic_launcher_foreground.xml
│ ├── ic_place_holder_24dp.xml
│ ├── ic_update_found.xml
│ └── notification_debug.xml
│ ├── layout
│ ├── activity_app_list.xml
│ ├── activity_debug_log.xml
│ ├── fragment_app_installer.xml
│ ├── row_app_item.xml
│ └── row_debug_log.xml
│ ├── menu
│ └── app_list_activity_menu.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ └── ic_launcher_round.png
│ ├── values-night
│ └── themes.xml
│ └── values
│ ├── colors.xml
│ ├── dimens.xml
│ ├── fullscreen_dialog_slide_animation_constants.xml
│ ├── ic_launcher_background.xml
│ ├── strings.xml
│ ├── style.xml
│ └── themes.xml
├── build.gradle
├── commonSettingsAll.gradle
├── commonSettingsLibs.gradle
├── gradle
├── libs.versions.toml
└── wrapper
│ └── gradle-wrapper.properties
├── notify
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── io
│ │ └── karn
│ │ └── notify
│ │ ├── Notify.kt
│ │ ├── NotifyCreator.kt
│ │ ├── entities
│ │ ├── NotificationChannelGroupInfo.kt
│ │ ├── NotifyConfig.kt
│ │ └── Payload.kt
│ │ └── internal
│ │ ├── NotificationChannelInterop.kt
│ │ ├── NotificationInterop.kt
│ │ ├── NotifyExtender.kt
│ │ ├── RawNotification.kt
│ │ └── utils
│ │ ├── Aliases.kt
│ │ ├── Annotations.kt
│ │ ├── Errors.kt
│ │ └── Utils.kt
│ └── res
│ └── drawable
│ ├── ic_android_black.xml
│ └── ic_app_icon.xml
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/gradle,android,intellij,androidstudio,kotlin,java
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=gradle,android,intellij,androidstudio,kotlin,java
3 |
4 | ### NLL Sensetive! ###
5 | gradle.properties
6 | appPrivateResources
7 | appSecrets/
8 | agconnect-services.json
9 | google-services.json
10 | onedrive_auth_config.json
11 | proguard-mappings/
12 |
13 |
14 |
15 | ### Android ###
16 | # Gradle files
17 | .gradle/
18 | build/
19 |
20 | # Local configuration file (sdk path, etc)
21 | local.properties
22 |
23 | # Log/OS Files
24 | *.log
25 |
26 | # Android Studio generated files and folders
27 | captures/
28 | .externalNativeBuild/
29 | .cxx/
30 | *.apk
31 | output.json
32 |
33 | # IntelliJ
34 | *.iml
35 | .idea/
36 |
37 | # Keystore files
38 | *.jks
39 | *.keystore
40 |
41 | # Google Services (e.g. APIs or Firebase)
42 | google-services.json
43 |
44 | # Android Profiling
45 | *.hprof
46 |
47 | ### Android Patch ###
48 | gen-external-apklibs
49 |
50 | # Replacement of .externalNativeBuild directories introduced
51 | # with Android Studio 3.5.
52 |
53 | ### Intellij ###
54 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
55 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
56 |
57 | # User-specific stuff
58 | .idea/**/workspace.xml
59 | .idea/**/tasks.xml
60 | .idea/**/usage.statistics.xml
61 | .idea/**/dictionaries
62 | .idea/**/shelf
63 |
64 | # AWS User-specific
65 | .idea/**/aws.xml
66 |
67 | # Generated files
68 | .idea/**/contentModel.xml
69 |
70 | # Sensitive or high-churn files
71 | .idea/**/dataSources/
72 | .idea/**/dataSources.ids
73 | .idea/**/dataSources.local.xml
74 | .idea/**/sqlDataSources.xml
75 | .idea/**/dynamic.xml
76 | .idea/**/uiDesigner.xml
77 | .idea/**/dbnavigator.xml
78 |
79 | # Gradle
80 | .idea/**/gradle.xml
81 | .idea/**/libraries
82 |
83 | # Gradle and Maven with auto-import
84 | # When using Gradle or Maven with auto-import, you should exclude module files,
85 | # since they will be recreated, and may cause churn. Uncomment if using
86 | # auto-import.
87 | # .idea/artifacts
88 | # .idea/compiler.xml
89 | # .idea/jarRepositories.xml
90 | # .idea/modules.xml
91 | # .idea/*.iml
92 | # .idea/modules
93 | # *.iml
94 | # *.ipr
95 |
96 | # CMake
97 | cmake-build-*/
98 |
99 | # Mongo Explorer plugin
100 | .idea/**/mongoSettings.xml
101 |
102 | # File-based project format
103 | *.iws
104 |
105 | # IntelliJ
106 | out/
107 |
108 | # mpeltonen/sbt-idea plugin
109 | .idea_modules/
110 |
111 | # JIRA plugin
112 | atlassian-ide-plugin.xml
113 |
114 | # Cursive Clojure plugin
115 | .idea/replstate.xml
116 |
117 | # SonarLint plugin
118 | .idea/sonarlint/
119 |
120 | # Crashlytics plugin (for Android Studio and IntelliJ)
121 | com_crashlytics_export_strings.xml
122 | crashlytics.properties
123 | crashlytics-build.properties
124 | fabric.properties
125 |
126 | # Editor-based Rest Client
127 | .idea/httpRequests
128 |
129 | # Android studio 3.1+ serialized cache file
130 | .idea/caches/build_file_checksums.ser
131 |
132 | ### Intellij Patch ###
133 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
134 |
135 | *.iml
136 | modules.xml
137 | .idea/misc.xml
138 | *.ipr
139 |
140 | # Sonarlint plugin
141 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
142 | .idea/**/sonarlint/
143 |
144 | # SonarQube Plugin
145 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
146 | .idea/**/sonarIssues.xml
147 |
148 | # Markdown Navigator plugin
149 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
150 | .idea/**/markdown-navigator.xml
151 | .idea/**/markdown-navigator-enh.xml
152 | .idea/**/markdown-navigator/
153 |
154 | # Cache file creation bug
155 | # See https://youtrack.jetbrains.com/issue/JBR-2257
156 | .idea/$CACHE_FILE$
157 |
158 | # CodeStream plugin
159 | # https://plugins.jetbrains.com/plugin/12206-codestream
160 | .idea/codestream.xml
161 |
162 | ### Java ###
163 | # Compiled class file
164 | *.class
165 |
166 | # Log file
167 |
168 | # BlueJ files
169 | *.ctxt
170 |
171 | # Mobile Tools for Java (J2ME)
172 | .mtj.tmp/
173 |
174 | # Package Files #
175 | *.jar
176 | *.war
177 | *.nar
178 | *.ear
179 | *.zip
180 | *.tar.gz
181 | *.rar
182 |
183 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
184 | hs_err_pid*
185 | replay_pid*
186 |
187 | ### Kotlin ###
188 | # Compiled class file
189 |
190 | # Log file
191 |
192 | # BlueJ files
193 |
194 | # Mobile Tools for Java (J2ME)
195 |
196 | # Package Files #
197 |
198 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
199 |
200 | ### Gradle ###
201 | .gradle
202 | **/build/
203 | !src/**/build/
204 |
205 | # Ignore Gradle GUI config
206 | gradle-app.setting
207 |
208 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
209 | !gradle-wrapper.jar
210 |
211 | # Cache of project
212 | .gradletasknamecache
213 |
214 | # Eclipse Gradle plugin generated files
215 | # Eclipse Core
216 | .project
217 | # JDT-specific (Eclipse Java Development Tools)
218 | .classpath
219 |
220 | ### AndroidStudio ###
221 | # Covers files to be ignored for android development using Android Studio.
222 |
223 | # Built application files
224 | *.ap_
225 | *.aab
226 |
227 | # Files for the ART/Dalvik VM
228 | *.dex
229 |
230 | # Java class files
231 |
232 | # Generated files
233 | bin/
234 | gen/
235 |
236 | # Gradle files
237 |
238 | # Signing files
239 | .signing/
240 |
241 | # Local configuration file (sdk path, etc)
242 |
243 | # Proguard folder generated by Eclipse
244 | proguard/
245 |
246 | # Log Files
247 |
248 | # Android Studio
249 | /*/build/
250 | /*/local.properties
251 | /*/out
252 | /*/*/build
253 | /*/*/production
254 | .navigation/
255 | *.ipr
256 | *~
257 | *.swp
258 |
259 | # Keystore files
260 |
261 | # Google Services (e.g. APIs or Firebase)
262 | # google-services.json
263 |
264 | # Android Patch
265 |
266 | # External native build folder generated in Android Studio 2.2 and later
267 | .externalNativeBuild
268 |
269 | # NDK
270 | obj/
271 |
272 | # IntelliJ IDEA
273 | /out/
274 |
275 | # User-specific configurations
276 | .idea/caches/
277 | .idea/libraries/
278 | .idea/shelf/
279 | .idea/workspace.xml
280 | .idea/tasks.xml
281 | .idea/.name
282 | .idea/compiler.xml
283 | .idea/copyright/profiles_settings.xml
284 | .idea/encodings.xml
285 | .idea/misc.xml
286 | .idea/modules.xml
287 | .idea/scopes/scope_settings.xml
288 | .idea/dictionaries
289 | .idea/vcs.xml
290 | .idea/jsLibraryMappings.xml
291 | .idea/datasources.xml
292 | .idea/dataSources.ids
293 | .idea/sqlDataSources.xml
294 | .idea/dynamic.xml
295 | .idea/uiDesigner.xml
296 | .idea/assetWizardSettings.xml
297 | .idea/gradle.xml
298 | .idea/jarRepositories.xml
299 | .idea/navEditor.xml
300 |
301 | # Legacy Eclipse project files
302 | .cproject
303 | .settings/
304 |
305 | # Mobile Tools for Java (J2ME)
306 |
307 | # Package Files #
308 |
309 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
310 |
311 | ## Plugin-specific files:
312 |
313 | # mpeltonen/sbt-idea plugin
314 |
315 | # JIRA plugin
316 |
317 | # Mongo Explorer plugin
318 | .idea/mongoSettings.xml
319 |
320 | # Crashlytics plugin (for Android Studio and IntelliJ)
321 |
322 | ### AndroidStudio Patch ###
323 |
324 | !/gradle/wrapper/gradle-wrapper.jar
325 |
326 | # End of https://www.toptal.com/developers/gitignore/api/gradle,android,intellij,androidstudio,kotlin,java
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | NLL Store is an Android app store designed to provide a single source for installing our apps. It was primarily developed to address the latest restriction introduced on Android 14, which imposes further limitations on enabling Accessibility Service.
2 |
3 |
4 |
5 | **Android 15 Update (24/April/2024)**
6 |
7 |
8 | As we have suspected before (*see the last line of this page*), Google might be preparing to put the final nail on the coffin of call recording and end the cat and mouse game for good.
9 |
10 | I have just read a [dooming article]( https://www.androidauthority.com/android-15-enhanced-confirmation-mode-3436697/) about improvements to the “Enhanced Confirmation Mode”
11 | I can confirm that **there will be no call recording possibility with Accessibility Service on Android 15**, if Google releases improved “Enhanced Confirmation Mode”.
12 |
13 |
14 | Improvements to “Enhanced Confirmation Mode” will prevent enabling Accessibility Service of any app that is not installed by a “Trusted Store” essentially ending possibility of call recording on Android 15+
15 |
16 |
17 |
18 | **Originial intro**
19 |
20 |
21 |
22 | Google seems to be committed to stop call recording. They have restricted Accessibility Service usage on Google Play Store and placed it behind an install time permission called "android.permission.ACCESS\_RESTRICTED\_SETTINGS" for sideloaded apps on Android 13.
23 |
24 |
25 | To overcome this restriction, we created [APH](https://acr.app/) and published it with ACCESS\_RESTRICTED\_SETTINGS permission. However, it appears that this workaround is no longer effective on Android 14, as side-loaded apps are unable to activate Accessibility Service at all.
26 |
27 |
28 | On Android 14, in order to use Accessibility Service, apps must be installed from a store that utilizes a specific Android API called [PackageInstaller.Session](https://developer.android.com/reference/android/content/pm/PackageInstaller.Session). Therefore, we had to create our own app store, allowing users to download and sideload it, then install APH through NLL Store. While this process may seem cumbersome, it is currently the only way.
29 |
30 |
31 | Please note that NLL Store is mainly for phones such as Pixel, Xiomi, Oppo etc without manufacturer app stores. APH is already published on Samsung Galaxy Store and Huawei App Gallery. You can simply go to [APH website](https://acr.app/) and use direct links to APH on those stores to download it.
32 |
33 |
34 | We would not be surprised if Google have plans to limit the installation of store apps that can install other apps on future versions of Android!
35 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.android)
3 | }
4 | apply plugin: libs.plugins.android.application.get().pluginId
5 | apply from: "$rootProject.projectDir/commonSettingsAll.gradle"
6 | apply plugin: libs.plugins.kotlin.serialization.get().pluginId
7 |
8 | android {
9 | namespace commonValues.appBaseNameSpace
10 |
11 | defaultConfig {
12 | applicationId commonValues.appBaseNameSpace
13 |
14 | ksp {
15 | arg("room.schemaLocation", "$rootDir/schemas")
16 | arg("room.incremental", "true")
17 | }
18 | }
19 |
20 | applicationVariants.configureEach { variant ->
21 | variant.outputs.configureEach { output ->
22 | output.outputFileName = "nll-store.apk"
23 | }
24 |
25 |
26 | if (!buildType.debuggable) {
27 | assembleProvider.get().doLast {
28 | mappingFileProvider.get().files.forEach { proguardFile ->
29 | def newFile = new File("${rootDir}/proguard-mappings/${versionCode}.txt")
30 | project.logger.lifecycle("Project app applicationVariants proguardFile from ${proguardFile} to ${newFile.absolutePath}")
31 | proguardFile.renameTo(newFile)
32 | }
33 | }
34 | }
35 | }
36 | }
37 |
38 | dependencies {
39 | implementation(libs.bundles.acraBundle)
40 | implementation(libs.coil)
41 | implementation(libs.bundles.ktorBundle)
42 | implementation(libs.androidx.coreKtx)
43 | implementation(libs.androidx.appCompat)
44 | implementation(libs.androidx.activity)
45 | implementation(libs.bundles.acraBundle)
46 | implementation(libs.androidx.constraintLayout)
47 | implementation(libs.androidx.fragment)
48 | implementation(libs.androidx.recyclerView)
49 | implementation(libs.google.materialComponents)
50 | implementation(libs.square.okHttp.loggingInterceptor)
51 | implementation(libs.androidx.lifecycle.service)
52 | implementation(libs.androidx.workManagerRuntimeKtx)
53 | implementation project(":notify")
54 | }
55 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -repackageclasses ''
2 | -allowaccessmodification
3 | -keepattributes RuntimeVisibleAnnotations
4 |
5 | #proguard is removing PReferenceCompat! Keep it
6 | -keep public class * extends androidx.preference.PreferenceFragmentCompat
7 | -dontwarn java.lang.invoke.StringConcatFactory
8 | -dontwarn javax.annotation.processing.AbstractProcessor
9 | -dontwarn javax.annotation.processing.SupportedOptions
10 |
11 | # Please add these rules to your existing keep rules in order to suppress warnings.
12 | # This is generated automatically by the Android Gradle plugin.
13 | -dontwarn org.bouncycastle.jsse.BCSSLParameters
14 | -dontwarn org.bouncycastle.jsse.BCSSLSocket
15 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
16 | -dontwarn org.conscrypt.Conscrypt$Version
17 | -dontwarn org.conscrypt.Conscrypt
18 | -dontwarn org.conscrypt.ConscryptHostnameVerifier
19 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters
20 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket
21 | -dontwarn org.openjsse.net.ssl.OpenJSSE
22 | -dontwarn org.slf4j.impl.StaticLoggerBinder
23 | -dontwarn org.slf4j.impl.StaticMDCBinder
24 | -dontwarn okhttp3.internal.Util
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
28 |
29 |
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 |
63 |
64 |
65 |
69 |
70 |
74 |
75 |
76 |
77 |
83 |
84 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/ApkAttachmentProvider.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store
2 |
3 | import android.content.ContentProvider
4 | import android.content.ContentValues
5 | import android.content.Context
6 | import android.content.UriMatcher
7 | import android.database.Cursor
8 | import android.database.MatrixCursor
9 | import android.net.Uri
10 | import android.os.ParcelFileDescriptor
11 | import android.provider.MediaStore
12 | import com.nll.store.installer.FileDownloader
13 | import com.nll.store.log.CLog
14 | import java.io.File
15 |
16 | class ApkAttachmentProvider : ContentProvider() {
17 | private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
18 |
19 | companion object {
20 | private const val logTag = "ApkAttachmentProvider"
21 |
22 | private const val shareMatchPath = "share"
23 | private const val shareMatchId = 1
24 |
25 | //Important that it is matched to Manifest
26 | private fun buildRecordingFileShareAuthority(context: Context) = "${context.packageName}.ApkAttachmentProvider"
27 |
28 | private fun getContentUri(context: Context) = Uri.parse("content://${buildRecordingFileShareAuthority(context)}").buildUpon().appendPath(shareMatchPath).build()
29 |
30 | fun getUri(context: Context, fileName: String): Uri = getContentUri(context).buildUpon().appendPath(fileName).build()
31 |
32 |
33 | }
34 |
35 |
36 | override fun onCreate(): Boolean {
37 | if (CLog.isDebug()) {
38 | CLog.log(logTag, "onCreate()")
39 | }
40 | uriMatcher.addURI(buildRecordingFileShareAuthority(requireNotNull(context)), "$shareMatchPath/*", shareMatchId)
41 | return true
42 | }
43 |
44 |
45 | override fun query(incomingUri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? {
46 | if (CLog.isDebug()) {
47 | CLog.log(logTag, "query() -> uri: $incomingUri")
48 | CLog.log(logTag, "query() -> file : ${incomingUri.lastPathSegment}")
49 | }
50 |
51 | if (Uri.decode(incomingUri.toString()).contains("../")) {
52 | throw SecurityException("$incomingUri is not allowed")
53 | }
54 |
55 |
56 | val fileNameFromUri = incomingUri.lastPathSegment
57 | if (fileNameFromUri.isNullOrEmpty()) {
58 | if (CLog.isDebug()) {
59 | CLog.log(logTag, "query() -> file is NULL!")
60 | }
61 | return null
62 | }
63 |
64 | if (uriMatcher.match(incomingUri) != shareMatchId) {
65 |
66 | if (CLog.isDebug()) {
67 | CLog.log(logTag, "query() -> uriMatcher cannot match to $shareMatchId")
68 | }
69 | }
70 | if (context == null) {
71 | if (CLog.isDebug()) {
72 | CLog.log(logTag, "query() -> context is NULL")
73 | }
74 | return null
75 | }
76 | val fileToSend = File(FileDownloader.getBaseFolder(requireNotNull(context)), fileNameFromUri)
77 | if (!fileToSend.exists()) {
78 | if (CLog.isDebug()) {
79 | CLog.log(logTag, "query() -> Cannot find $fileNameFromUri")
80 | }
81 | return null
82 | }
83 |
84 | //In case projection is null. Some apps like Telegram or TotalCommander does that
85 | val localProjection = projection
86 | ?: arrayOf(MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.SIZE)
87 | val matrixCursor = MatrixCursor(localProjection)
88 | val rowBuilder = matrixCursor.newRow()
89 | matrixCursor.columnNames.forEach { column ->
90 | when {
91 |
92 | column.equals(MediaStore.MediaColumns.DISPLAY_NAME, ignoreCase = true) -> {
93 | rowBuilder.add(column, fileNameFromUri)
94 | }
95 |
96 | column.equals(MediaStore.MediaColumns.SIZE, ignoreCase = true) -> {
97 | rowBuilder.add(column, fileToSend.length())
98 | }
99 |
100 | column.equals(MediaStore.MediaColumns.MIME_TYPE, ignoreCase = true) -> {
101 | rowBuilder.add(column, "application/vnd.android.package-archive")
102 | }
103 |
104 | column.equals(MediaStore.MediaColumns.DATE_MODIFIED, ignoreCase = true) ||
105 | column.equals(MediaStore.MediaColumns.DATE_ADDED, ignoreCase = true) -> {
106 | rowBuilder.add(column, fileToSend.lastModified())
107 | }
108 | }
109 | }
110 | return matrixCursor
111 | }
112 |
113 |
114 | override fun openFile(incomingUri: Uri, mode: String): ParcelFileDescriptor? {
115 | if (CLog.isDebug()) {
116 | CLog.log(logTag, "openFile() -> incomingUri: $incomingUri")
117 | }
118 | if (Uri.decode(incomingUri.toString()).contains("../")) {
119 | throw SecurityException("$incomingUri is not allowed")
120 | }
121 |
122 |
123 | val fileNameFromUri = incomingUri.lastPathSegment
124 | if (fileNameFromUri.isNullOrEmpty()) {
125 | if (CLog.isDebug()) {
126 | CLog.log(logTag, "openFile() -> file name is NULL!")
127 | }
128 | return null
129 | }
130 |
131 | if (uriMatcher.match(incomingUri) != shareMatchId) {
132 |
133 | if (CLog.isDebug()) {
134 | CLog.log(logTag, "openFile() -> uriMatcher cannot match to $shareMatchId")
135 | }
136 | }
137 | if (context == null) {
138 | if (CLog.isDebug()) {
139 | CLog.log(logTag, "openFile() -> context is NULL")
140 | }
141 | return null
142 | }
143 |
144 | val fileToSend = File(FileDownloader.getBaseFolder(requireNotNull(context)), fileNameFromUri)
145 | if (!fileToSend.exists()) {
146 | if (CLog.isDebug()) {
147 | CLog.log(logTag, "openFile()() -> Cannot find $fileNameFromUri")
148 | }
149 | return null
150 | }
151 |
152 | return ParcelFileDescriptor.open(fileToSend, ParcelFileDescriptor.MODE_READ_ONLY)
153 |
154 |
155 | }
156 |
157 | override fun getType(uri: Uri): String {
158 | return when (uriMatcher.match(uri)) {
159 | shareMatchId -> return "application/vnd.android.package-archive"
160 | else -> ""
161 | }
162 | }
163 |
164 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
165 | throw UnsupportedOperationException()
166 | }
167 |
168 | override fun insert(uri: Uri, values: ContentValues?): Uri? {
169 | throw UnsupportedOperationException()
170 | }
171 |
172 | override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int {
173 | throw UnsupportedOperationException()
174 | }
175 |
176 |
177 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/App.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import coil.ImageLoader
6 | import coil.ImageLoaderFactory
7 | import coil.disk.DiskCache
8 | import coil.memory.MemoryCache
9 | import coil.request.CachePolicy
10 | import coil.util.DebugLogger
11 | import com.nll.store.connectivity.InternetStateProvider
12 | import com.nll.store.log.CLog
13 | import com.nll.store.update.PeriodicUpdateCheckWorker
14 | import kotlinx.coroutines.CoroutineScope
15 | import kotlinx.coroutines.Dispatchers
16 | import kotlinx.coroutines.SupervisorJob
17 | import org.acra.ReportField
18 | import org.acra.config.dialog
19 | import org.acra.config.mailSender
20 | import org.acra.config.notification
21 | import org.acra.data.StringFormat
22 | import org.acra.ktx.initAcra
23 | import java.util.concurrent.Executors
24 |
25 |
26 | class App : Application(), ImageLoaderFactory {
27 | companion object {
28 | private const val logTag = "App"
29 | lateinit var INSTANCE: App private set
30 | val applicationScope: CoroutineScope by lazy { CoroutineScope(Dispatchers.Main + SupervisorJob()) }
31 | val contactEmail = "cb@nllapps.com"
32 | }
33 |
34 |
35 | override fun attachBaseContext(base: Context) {
36 | super.attachBaseContext(base)
37 | //Use executor rather than coroutine https://medium.com/specto/android-startup-tip-dont-use-kotlin-coroutines-a7b3f7176fe5
38 | //However, he was wrong. https://medium.com/specto/dont-run-benchmarks-on-a-debuggable-android-app-like-i-did-34d95331cabb
39 | //Keep for now
40 | Executors.newSingleThreadExecutor().execute {
41 | initACRA()
42 | }
43 |
44 | }
45 |
46 | override fun onCreate() {
47 | super.onCreate()
48 | if (CLog.isDebug()) {
49 | CLog.log(logTag, "onCreate()")
50 | }
51 | INSTANCE = this
52 | initACRA()
53 | InternetStateProvider.start(this, false)
54 | PeriodicUpdateCheckWorker.enqueueUpdateCheck(this)
55 | }
56 |
57 |
58 | private fun initACRA() {
59 | if (CLog.isDebug()) {
60 | CLog.log(logTag, "initACRA()")
61 | }
62 | try {
63 | initAcra {
64 |
65 | buildConfigClass = BuildConfig::class.java
66 | reportFormat = StringFormat.KEY_VALUE_LIST
67 | reportContent = listOf(
68 | ReportField.USER_COMMENT,
69 | ReportField.PACKAGE_NAME,
70 | ReportField.APP_VERSION_NAME,
71 | ReportField.ANDROID_VERSION,
72 | ReportField.BRAND,
73 | ReportField.PHONE_MODEL,
74 | ReportField.PRODUCT,
75 | ReportField.USER_APP_START_DATE,
76 | ReportField.USER_CRASH_DATE,
77 | ReportField.STACK_TRACE,
78 | ReportField.LOGCAT
79 | )
80 |
81 | mailSender {
82 | mailTo = contactEmail
83 | reportAsFile = false
84 | }
85 |
86 | notification {
87 | title = getString(R.string.crash_notif_title)
88 | text = getString(R.string.crash_dialog_text)
89 | channelName = getString(R.string.app_crash_notification_channel)
90 | sendButtonText = getString(R.string.send)
91 | discardButtonText = getString(R.string.cancel)
92 | sendOnClick = true
93 | resDiscardButtonIcon = R.drawable.crash_log_discard
94 | resSendButtonIcon = R.drawable.crash_log_send
95 | }
96 |
97 | //Notification may not work, also use dialog for now. See https://github.com/ACRA/acra/issues/1146
98 | dialog {
99 | title = getString(R.string.crash_notif_title)
100 | text = getString(R.string.crash_dialog_text)
101 |
102 | }
103 | }
104 |
105 |
106 | } catch (e: Exception) {
107 | //Already called. Ignore. It seems to be called more than once on rare occasions
108 | CLog.logPrintStackTrace(e)
109 | }
110 | }
111 |
112 | override fun newImageLoader() = ImageLoader.Builder(this)
113 | .memoryCachePolicy(CachePolicy.ENABLED)
114 | .diskCachePolicy(CachePolicy.ENABLED)
115 | .logger(DebugLogger())
116 | .respectCacheHeaders(false)
117 | .memoryCache {
118 | MemoryCache.Builder(this)
119 | .maxSizePercent(0.25)
120 | .build()
121 | }
122 | .diskCache {
123 | DiskCache.Builder()
124 | .directory(cacheDir.resolve("image_cache"))
125 | .maxSizeBytes(5 * 1024 * 1024)
126 | .build()
127 | }
128 | .build()
129 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/activityresult/InstallPermissionContract.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.activityresult
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.provider.Settings
7 | import androidx.activity.result.contract.ActivityResultContract
8 |
9 | class InstallPermissionContract(private val context: Context) : ActivityResultContract() {
10 |
11 | override fun createIntent(context: Context, input: Unit): Intent = Intent(
12 | Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
13 | Uri.parse("package:${context.packageName}")
14 | )
15 |
16 | override fun parseResult(resultCode: Int, intent: Intent?): Boolean =
17 | context.packageManager.canRequestPackageInstalls()
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/ApiClient.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import com.nll.store.log.CLog
4 | import io.ktor.client.HttpClient
5 | import io.ktor.client.plugins.HttpRequestRetry
6 | import io.ktor.client.plugins.HttpTimeout
7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8 | import io.ktor.client.plugins.defaultRequest
9 | import io.ktor.client.plugins.logging.LogLevel
10 | import io.ktor.client.plugins.logging.Logger
11 | import io.ktor.client.plugins.logging.Logging
12 | import io.ktor.client.request.accept
13 | import io.ktor.http.ContentType
14 | import io.ktor.serialization.kotlinx.KotlinxSerializationConverter
15 | import io.ktor.util.appendIfNameAbsent
16 | import kotlinx.serialization.json.Json
17 | import kotlin.time.Duration.Companion.seconds
18 | import kotlin.time.DurationUnit
19 |
20 | object ApiClient {
21 | private const val logTag = "ApiClient"
22 | private val timeoutConfig = Timeout(request = 30.seconds, connect = 30.seconds, socket = 30.seconds)
23 | private val retryStrategy = RetryStrategy()
24 | private val headerParams: Map = emptyMap()
25 | private val queryParams: Map = emptyMap()
26 | internal fun createHttpClient(apiUrl: String) = HttpClient {
27 |
28 | install(ContentNegotiation) {
29 | register(
30 | ContentType.Application.Json,
31 | KotlinxSerializationConverter(
32 | Json {
33 | isLenient = true
34 | ignoreUnknownKeys = true
35 | }
36 | )
37 | )
38 | }
39 |
40 | install(Logging) {
41 | level = LogLevel.ALL
42 | logger = object : Logger {
43 | override fun log(message: String) {
44 | if (CLog.isDebug()) {
45 | CLog.log(logTag, message)
46 | }
47 | }
48 | }
49 | }
50 |
51 |
52 |
53 | install(HttpTimeout) {
54 | socketTimeoutMillis = timeoutConfig.socket.toLong(DurationUnit.MILLISECONDS)
55 | connectTimeoutMillis = timeoutConfig.connect.toLong(DurationUnit.MILLISECONDS)
56 | requestTimeoutMillis = timeoutConfig.request.toLong(DurationUnit.MILLISECONDS)
57 | }
58 |
59 | install(HttpRequestRetry) {
60 | maxRetries = retryStrategy.maxRetries
61 | // retry on rate limit error.
62 | retryIf { _, response -> response.status.value.let { it == 429 } }
63 | exponentialDelay(retryStrategy.base, retryStrategy.maxDelay.inWholeMilliseconds)
64 | }
65 |
66 | defaultRequest {
67 | url(apiUrl)
68 | accept(ContentType.Any)
69 | queryParams.onEach { (key, value) -> url.parameters.appendIfNameAbsent(key, value) }
70 | headerParams.onEach { (key, value) -> headers.appendIfNameAbsent(key, value) }
71 | }
72 |
73 | expectSuccess = true
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/ApiException.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | sealed class ApiException(
4 | message: String? = null,
5 | throwable: Throwable? = null
6 | ) : RuntimeException(message, throwable) {
7 | class TimeoutException(
8 | throwable: Throwable
9 | ) : ApiException(message = throwable.message, throwable = throwable)
10 |
11 | class ServerException(
12 | throwable: Throwable? = null,
13 | ) : ApiException(message = throwable?.message, throwable = throwable)
14 | class UnknownHostException(
15 | throwable: Throwable? = null
16 | ) : ApiException(message = throwable?.message, throwable = throwable)
17 |
18 | class GenericException(
19 | throwable: Throwable? = null,
20 | ) : ApiException(throwable?.message, throwable)
21 | sealed class HttpException(
22 | statusCode: Int,
23 | throwable: Throwable? = null,
24 | ) : ApiException(throwable?.message, throwable) {
25 | class AuthenticationException(
26 | statusCode: Int,
27 | throwable: Throwable? = null
28 | ) : HttpException(statusCode, throwable)
29 |
30 | class PermissionException(
31 | statusCode: Int,
32 | throwable: Throwable? = null
33 | ) : HttpException(statusCode, throwable)
34 |
35 |
36 | class UnknownException(
37 | statusCode: Int,
38 | throwable: Throwable? = null
39 | ) : HttpException(statusCode, throwable)
40 |
41 | }
42 |
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/HttpRequester.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.request.HttpRequestBuilder
5 | import io.ktor.client.statement.HttpResponse
6 | import io.ktor.util.reflect.TypeInfo
7 | import io.ktor.util.reflect.typeInfo
8 |
9 |
10 | /**
11 | * Http request performer.
12 | */
13 | internal interface HttpRequester: AutoCloseable {
14 |
15 | /**
16 | * Perform an HTTP request and get a result.
17 | */
18 | suspend fun perform(info: TypeInfo, block: suspend (HttpClient) -> HttpResponse): T
19 |
20 | /**
21 | * Perform an HTTP request and get a result.
22 | *
23 | * Note: [HttpResponse] instance shouldn't be passed outside of [block].
24 | */
25 | suspend fun perform(
26 | builder: HttpRequestBuilder,
27 | block: suspend (response: HttpResponse) -> T
28 | )
29 | }
30 |
31 | /**
32 | * Perform an HTTP request and get a result
33 | */
34 | internal suspend inline fun HttpRequester.perform(noinline block: suspend (HttpClient) -> HttpResponse): T {
35 | return perform(typeInfo(), block)
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/HttpTransport.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.call.body
5 | import io.ktor.client.network.sockets.ConnectTimeoutException
6 | import io.ktor.client.network.sockets.SocketTimeoutException
7 | import io.ktor.client.plugins.ClientRequestException
8 | import io.ktor.client.plugins.HttpRequestTimeoutException
9 | import io.ktor.client.plugins.ServerResponseException
10 | import io.ktor.client.request.HttpRequestBuilder
11 | import io.ktor.client.statement.HttpResponse
12 | import io.ktor.client.statement.HttpStatement
13 | import io.ktor.util.reflect.TypeInfo
14 | import kotlinx.coroutines.CancellationException
15 | import java.net.UnknownHostException
16 |
17 | /** HTTP transport layer */
18 | internal class HttpTransport(private val httpClient: HttpClient) : HttpRequester {
19 |
20 | /** Perform an HTTP request and get a result */
21 | override suspend fun perform(info: TypeInfo, block: suspend (HttpClient) -> HttpResponse): T {
22 | try {
23 | val response = block(httpClient)
24 | return response.body(info)
25 | } catch (e: Exception) {
26 | throw handleException(e)
27 | }
28 | }
29 |
30 | override suspend fun perform(
31 | builder: HttpRequestBuilder,
32 | block: suspend (response: HttpResponse) -> T
33 | ) {
34 | try {
35 | HttpStatement(builder = builder, client = httpClient).execute(block)
36 | } catch (e: Exception) {
37 | throw handleException(e)
38 | }
39 | }
40 |
41 | override fun close() {
42 | httpClient.close()
43 | }
44 |
45 |
46 | private suspend fun handleException(e: Throwable) = when (e) {
47 | is CancellationException -> e // propagate coroutine cancellation
48 | is ClientRequestException -> apiException(e)
49 | is ServerResponseException -> ApiException.ServerException(e)
50 | is HttpRequestTimeoutException, is SocketTimeoutException, is ConnectTimeoutException -> ApiException.TimeoutException(e)
51 | is UnknownHostException -> ApiException.UnknownHostException(e)
52 | else -> ApiException.GenericException(e)
53 | }
54 |
55 | private fun apiException(exception: ClientRequestException): ApiException {
56 | val response = exception.response
57 | return when (val status = response.status.value) {
58 | 401 -> ApiException.HttpException.AuthenticationException(status, exception)
59 | 403 -> ApiException.HttpException.PermissionException(status, exception)
60 | else -> ApiException.HttpException.UnknownException(status, exception)
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/NLLStoreApi.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import com.nll.store.model.StoreAppData
4 | import io.ktor.client.request.get
5 | import io.ktor.client.request.url
6 | import io.ktor.http.ContentType
7 | import io.ktor.http.contentType
8 |
9 | class NLLStoreApi() {
10 | private val apiUrl = "https://nllapps.com/store/api/"
11 | private val transport = HttpTransport(ApiClient.createHttpClient(apiUrl))
12 | suspend fun getStoreAppList(): List = transport.perform {
13 | it.get {
14 | url(path = "apps.json")
15 | contentType(ContentType.Any)
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/PackageReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import com.nll.store.log.CLog
7 |
8 |
9 | class PackageReceiver(private val callBack: (String) -> Unit) : BroadcastReceiver() {
10 | private val logTag = "PackageReceiver"
11 | override fun onReceive(context: Context, intent: Intent?) {
12 | if (CLog.isDebug()) {
13 | CLog.log(logTag, "onReceive() -> intent $intent")
14 | }
15 | if (intent?.action == Intent.ACTION_PACKAGE_REMOVED) {
16 | val isReplacing = intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
17 | val packageName = intent.data?.schemeSpecificPart
18 | if (CLog.isDebug()) {
19 | CLog.log(logTag, "onReceive() -> isReplacing: $isReplacing, packageName: $packageName")
20 | }
21 | if(!isReplacing && packageName != null){
22 | callBack.invoke(packageName)
23 | }
24 |
25 | } else if (intent?.action == Intent.ACTION_PACKAGE_ADDED) {
26 | val packageName = intent.data?.schemeSpecificPart
27 | if (packageName != null) {
28 | if (CLog.isDebug()) {
29 | CLog.log(logTag, "onReceive() -> Name of package changed: packageName: $packageName")
30 | }
31 | callBack.invoke(packageName)
32 | }
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/RetryStrategy.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import kotlin.time.Duration
4 | import kotlin.time.Duration.Companion.seconds
5 |
6 | data class RetryStrategy(
7 | val maxRetries: Int = 3,
8 | val base: Double = 2.0,
9 | val maxDelay: Duration = 60.seconds,
10 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/StoreApiManager.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import android.content.pm.PackageManager
8 | import androidx.core.content.ContextCompat
9 | import com.nll.store.log.CLog
10 | import com.nll.store.model.AppData
11 | import com.nll.store.model.AppInstallState
12 | import com.nll.store.model.LocalAppData
13 | import com.nll.store.model.StoreAppData
14 | import com.nll.store.model.StoreConnectionState
15 | import com.nll.store.update.UpdateNotification
16 | import com.nll.store.utils.ApiLevel
17 | import com.nll.store.utils.CoroutineScopeFactory
18 | import com.nll.store.utils.SingletonHolder
19 | import com.nll.store.utils.getInstalledApplicationsCompat
20 | import kotlinx.coroutines.Dispatchers
21 | import kotlinx.coroutines.flow.MutableStateFlow
22 | import kotlinx.coroutines.flow.asStateFlow
23 | import kotlinx.coroutines.launch
24 | import java.util.concurrent.TimeUnit
25 |
26 | class StoreApiManager private constructor(private val applicationContext: Context) {
27 | private val logTag = "StoreApiManager"
28 | private val iOScope by lazy { CoroutineScopeFactory.create(Dispatchers.IO) }
29 | private val nllPackages = "com.nll."
30 | private val nllStoreApi = NLLStoreApi()
31 | private val _appsList = MutableStateFlow(listOf())
32 | private val _storeConnectionState = MutableStateFlow(StoreConnectionState.Connected)
33 | private var lastStoreAppListLoadTime = 0L
34 | private var storeAppList: List = listOf()
35 |
36 | companion object : SingletonHolder({
37 | StoreApiManager(it.applicationContext)
38 | })
39 |
40 | init {
41 | registerPackageReceiver()
42 | }
43 |
44 |
45 | fun observeAppList() = _appsList.asStateFlow()
46 | fun observeStoreConnectionState() = _storeConnectionState.asStateFlow()
47 | fun loadAppList() {
48 |
49 | //Rate limit remote connectivity
50 | val shouldLoadFromRemote = TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis() - lastStoreAppListLoadTime) > 60
51 | if (CLog.isDebug()) {
52 | CLog.log(logTag, "loadAppList() -> shouldLoadFromRemote: $shouldLoadFromRemote")
53 | }
54 |
55 | _storeConnectionState.value = StoreConnectionState.Connecting
56 |
57 | iOScope.launch {
58 | val localAppList = try {
59 | getInstalledAppsList()
60 | } catch (e: Exception) {
61 | if (CLog.isDebug()) {
62 | CLog.log(logTag, "loadAppList() -> Error while loading localAppList")
63 | }
64 | listOf()
65 | }
66 | if (CLog.isDebug()) {
67 | CLog.log(logTag, "loadAppList() -> localAppList: ${localAppList.joinToString("\n")}")
68 | }
69 | try {
70 | storeAppList = if (shouldLoadFromRemote) {
71 | nllStoreApi.getStoreAppList()
72 | } else {
73 | storeAppList
74 | }
75 | if (CLog.isDebug()) {
76 | CLog.log(logTag, "loadAppList() -> storeAppList: ${storeAppList.joinToString("\n")}")
77 | }
78 | _appsList.value = storeAppList.map { storeAppData ->
79 | val localAppData = localAppList.firstOrNull { it.packageName == storeAppData.packageName }
80 | val appInstallState = if (localAppData == null) {
81 | AppInstallState.NotInstalled
82 | } else {
83 | AppInstallState.Installed(localAppData)
84 | }
85 | AppData(storeAppData, appInstallState)
86 | }
87 | lastStoreAppListLoadTime = System.currentTimeMillis()
88 | _storeConnectionState.value = StoreConnectionState.Connected
89 | } catch (e: Exception) {
90 | if (CLog.isDebug()) {
91 | CLog.log(logTag, "loadAppList() -> Error while requesting app list!")
92 | }
93 | CLog.logPrintStackTrace(e)
94 | _storeConnectionState.value = StoreConnectionState.Failed(e as? ApiException ?: ApiException.GenericException(e))
95 |
96 | }
97 | }
98 |
99 | }
100 |
101 |
102 | private fun getInstalledAppsList() = applicationContext.packageManager.getInstalledApplicationsCompat(0)
103 | .filter { it.packageName.startsWith(nllPackages) }
104 | .map {
105 | val icon = applicationContext.packageManager.getApplicationIcon(it)
106 | val appName = applicationContext.packageManager.getApplicationLabel(it) as String
107 | val packageName = it.packageName
108 | /*
109 | Be careful when replacing deprecated method below with something like PackageManager.PackageInfoFlags.of(PackageManager.GET_INSTRUMENTATION)
110 | getPackageInfo(packageName, 0) returns all info. We need to make sure new method would return all the info we use
111 | for example, we need firstInstallTime. Which PackageManager.GET_.. returns it?
112 | */
113 | val versionCode = applicationContext.packageManager.getPackageInfo(packageName, 0).longVersionCode
114 | LocalAppData(packageName.hashCode(), icon, appName, packageName, versionCode)
115 | }
116 | .sortedBy { it.name }
117 |
118 | private fun registerPackageReceiver() {
119 | if (CLog.isDebug()) {
120 | CLog.log(logTag, "registerPackageReceiver()")
121 | }
122 | val pkgFilter = IntentFilter().apply {
123 | priority = IntentFilter.SYSTEM_HIGH_PRIORITY
124 | addAction(Intent.ACTION_PACKAGE_REMOVED)
125 | addAction(Intent.ACTION_PACKAGE_REPLACED)
126 | addAction(Intent.ACTION_PACKAGE_DATA_CLEARED)
127 | addAction(Intent.ACTION_PACKAGE_ADDED)
128 | addAction(Intent.ACTION_PACKAGE_CHANGED)
129 | addDataScheme("package")
130 | }
131 |
132 | ContextCompat.registerReceiver(applicationContext, PackageReceiver { packageName ->
133 | if (CLog.isDebug()) {
134 | CLog.log(logTag, "registerPackageReceiver() -> callback() -> packageName: $packageName")
135 | }
136 | loadAppList()
137 | }, pkgFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
138 | }
139 |
140 | /**
141 | * TODO Implement automatic updating if APH
142 | */
143 | fun checkUpdates() {
144 | if (CLog.isDebug()) {
145 | CLog.log(logTag, "checkUpdates()")
146 | }
147 |
148 | if (hasNotificationPermission()) {
149 | //Update app list cache
150 | loadAppList()
151 | //Check for updates
152 | val postUpdateNotification = _appsList.value.any { it.canBeUpdated() }
153 | if (CLog.isDebug()) {
154 | CLog.log(logTag, "checkUpdates() -> postUpdateNotification: $postUpdateNotification")
155 | }
156 | if(postUpdateNotification){
157 | UpdateNotification.postUpdateNotification(applicationContext)
158 | }
159 | } else {
160 | if (CLog.isDebug()) {
161 | CLog.log(logTag, "checkUpdates() -> We do not have notification permission. Skipping update check")
162 | }
163 | }
164 |
165 | }
166 |
167 | private fun hasNotificationPermission() = if (ApiLevel.isTPlus()) {
168 | ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
169 | } else {
170 | true
171 | }
172 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/api/Timeout.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.api
2 | import kotlin.time.Duration
3 |
4 | /**
5 | * Http operations timeouts.
6 | *
7 | * @param request time period required to process an HTTP call: from sending a request to receiving a response
8 | * @param connect time period in which a client should establish a connection with a server
9 | * @param socket maximum time of inactivity between two data packets when exchanging data with a server
10 | */
11 | data class Timeout(
12 | val request: Duration,
13 | val connect: Duration,
14 | val socket: Duration,
15 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/connectivity/InternetStateMonitor.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.connectivity
2 |
3 | import android.net.ConnectivityManager
4 | import android.net.Network
5 | import android.net.NetworkCapabilities
6 | import com.nll.store.log.CLog
7 | import com.nll.store.utils.ApiLevel
8 |
9 | internal class InternetStateMonitor(private val connectivityManager: ConnectivityManager?, private val callBack: (NetworkState?) -> Unit) : ConnectivityManager.NetworkCallback() {
10 | private val logTag = "InternetStateMonitor"
11 | private var callbacksRegistered = false
12 | private var currentNetworkState: NetworkState? = connectivityManager?.extActiveNetworkState
13 |
14 | init {
15 | if (CLog.isDebug()) {
16 | CLog.log(logTag, "init")
17 | }
18 |
19 | //Emit initial status before ve register callbacks. Because is initial status we get from connectivityManager?.extActiveNetworkState is same as we get on call back
20 | //No emit would happen due to checks we make at updateNetworkState()
21 | callBack(currentNetworkState)
22 |
23 | tryRegister()
24 | }
25 |
26 | /*
27 | Due to https://issuetracker.google.com/issues/175055271
28 | */
29 | fun ensureRegistered() {
30 | if (callbacksRegistered) {
31 | if (CLog.isDebug()) {
32 | CLog.log(logTag, "ensureRegistered() -> callbacksRegistered was True. Ignoring request")
33 | }
34 | } else {
35 | if (CLog.isDebug()) {
36 | CLog.log(logTag, "ensureRegistered() -> callbacksRegistered was False. Trying to register again")
37 | }
38 | tryRegister()
39 | }
40 | }
41 |
42 | private fun tryRegister() {
43 | /*
44 | Due to https://issuetracker.google.com/issues/175055271
45 | */
46 | try {
47 | /**
48 | * Interesting fact.
49 | * Using object : ConnectivityManager.NetworkCallback as a variable of this class caused
50 | * registerDefaultNetworkCallback to complain that callback was null
51 | */
52 | connectivityManager?.registerDefaultNetworkCallback(this)
53 | callbacksRegistered = true
54 | if (CLog.isDebug()) {
55 | CLog.log(logTag, "tryRegister() -> Callback registered")
56 | }
57 | } catch (e: Exception) {
58 | if (CLog.isDebug()) {
59 | CLog.log(logTag, "tryRegister() -> Unable to register callback")
60 | }
61 | CLog.logPrintStackTrace(e)
62 |
63 | }
64 | }
65 |
66 | fun isConnectionUnMetered(): Boolean {
67 | val currentNetworkStateIsUnMetered = currentNetworkState?.isConnectionUnMetered()
68 | if (CLog.isDebug()) {
69 | CLog.log(logTag, "isConnectionUnMetered() -> currentNetworkStateIsUnMetered: $currentNetworkStateIsUnMetered")
70 | }
71 |
72 | return if (currentNetworkStateIsUnMetered != null) {
73 | currentNetworkStateIsUnMetered
74 | } else {
75 | val extIsActiveConnectionUnMetered = connectivityManager?.extIsActiveConnectionUnMetered ?: false
76 | if (CLog.isDebug()) {
77 | CLog.log(logTag, "isConnectionUnMetered() -> extIsActiveConnectionUnMetered: $extIsActiveConnectionUnMetered")
78 | }
79 | extIsActiveConnectionUnMetered
80 | }
81 | }
82 |
83 | override fun onLost(network: Network) {
84 | if (CLog.isDebug()) {
85 | CLog.log(logTag, "networkCallback() -> onLost() -> network: $network")
86 | }
87 | updateNetworkState()
88 |
89 | }
90 |
91 | override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
92 | /*if (CLog.isDebug()) {
93 | CLog.log(logTag, "networkCallback() -> onCapabilitiesChanged() -> networkCapabilities: $networkCapabilities")
94 | }*/
95 | updateNetworkState()
96 |
97 | }
98 |
99 | private fun updateNetworkState() {
100 |
101 | connectivityManager?.let { manager ->
102 | val networkState = manager.extActiveNetworkState
103 | if (networkState != currentNetworkState) {
104 | if (CLog.isDebug()) {
105 | CLog.log(logTag, "updateNetworkState() -> Posting update. New state: $networkState, oldState: $currentNetworkState")
106 | }
107 | currentNetworkState = networkState
108 | callBack(networkState)
109 |
110 | }
111 | }
112 | }
113 |
114 |
115 | private val ConnectivityManager.extActiveNetworkState: NetworkState?
116 | get() {
117 | return try {
118 | val networkCapability = getNetworkCapabilities(activeNetwork)
119 | if (CLog.isDebug()) {
120 | CLog.log(logTag, "extActiveNetworkState -> networkCapability: $networkCapability")
121 | }
122 | val hasInternetCapability = networkCapability?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false
123 | val hasNotRestrictedInternetCapability = networkCapability?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) ?: false
124 | val canConnectToTheInternet = hasInternetCapability && hasNotRestrictedInternetCapability
125 | val isUnMetered = networkCapability?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) ?: true
126 | val isNotRoaming = if (ApiLevel.isPiePlus()) {
127 | networkCapability?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) ?: true
128 | } else {
129 | true
130 | }
131 | val networkHandle = activeNetwork?.networkHandle ?: 0L
132 | NetworkState(hasInternetCapability = canConnectToTheInternet, isMetered = isUnMetered.not(), isRoaming = isNotRoaming.not(), networkHandle = networkHandle)
133 | } catch (e: Exception) {
134 | CLog.logPrintStackTrace(e)
135 | null
136 | }
137 | }
138 |
139 | private val ConnectivityManager.extIsActiveConnectionUnMetered: Boolean
140 | get() {
141 | return isActiveNetworkMetered.not()
142 |
143 | }
144 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/connectivity/InternetStateProvider.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.connectivity
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.os.Handler
6 | import android.os.Looper
7 | import android.provider.Settings
8 | import com.nll.store.log.CLog
9 | import com.nll.store.utils.ApiLevel
10 | import com.nll.store.utils.extConnectivityManager
11 | import com.nll.store.utils.extDebounce
12 | import com.nll.store.utils.extTryStartActivity
13 | import kotlinx.coroutines.flow.MutableStateFlow
14 | import kotlinx.coroutines.flow.asStateFlow
15 |
16 |
17 | /**
18 |
19 | InternetStateProvider.isDeviceOnline()
20 | or
21 |
22 | InternetStateProvider.isDeviceOnlineFlow().onEach { isDeviceOnline ->
23 |
24 | }.launchIn(lifecycleScope)
25 |
26 | */
27 | object InternetStateProvider {
28 | private const val logTag = "InternetStateProvider"
29 | private const val delayedStartUpStatusCheckTimeInMs = 5000L
30 | private var internetStateMonitor: InternetStateMonitor? = null
31 | private var networkState = MutableStateFlow(NetworkState(hasInternetCapability = false, isMetered = false, isRoaming = false, 0L))
32 |
33 | /**
34 | * Getting networkState connectivity takes a little bit time and some functions use isDeviceOnline() as soon as app started.
35 | * This is the best way to make sure we get real connectivity result without waiting for networkState
36 | */
37 | private var deprecatedIsOnline = false
38 |
39 | @Suppress("DEPRECATION")
40 | fun updateDeprecatedIsOnline(context: Context) {
41 | deprecatedIsOnline = context.extConnectivityManager()?.activeNetworkInfo != null
42 | if (CLog.isDebug()) {
43 | CLog.log(logTag, "updateDeprecatedIsOnline -> deprecatedIsOnline: $deprecatedIsOnline")
44 | }
45 | }
46 |
47 | fun openQuickInterNetConnectivityMenuIfYouCan(context: Context) {
48 | if (ApiLevel.isQPlus()) {
49 | context.extTryStartActivity(Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY))
50 | }
51 | }
52 |
53 | private fun ensureInternetStateMonitorIsRegistered() {
54 | if (CLog.isDebug()) {
55 | CLog.log(logTag, "ensureInternetStateMonitorIsRegistered() -> internetStateMonitor : $internetStateMonitor")
56 | }
57 | internetStateMonitor?.ensureRegistered()
58 | }
59 |
60 |
61 | internal fun start(context: Context, skipDelayedStatusCheck: Boolean) {
62 | if (CLog.isDebug()) {
63 | CLog.log(logTag, "start -> skipDelayedStatusCheck: $skipDelayedStatusCheck, Start observing Internet state")
64 | }
65 | updateDeprecatedIsOnline(context)
66 | if (CLog.isDebug()) {
67 | CLog.log(logTag, "start -> deprecatedIsOnline: $deprecatedIsOnline")
68 | }
69 | internetStateMonitor = InternetStateMonitor(context.extConnectivityManager()) { state ->
70 |
71 | if (CLog.isDebug()) {
72 | CLog.log(logTag, "internetStateMonitor -> callBack -> state is now $state")
73 | }
74 |
75 | /**
76 | * We may get
77 | * java.lang.SecurityException: Package android does not belong to 10265
78 | *
79 | * Due to https://issuetracker.google.com/issues/175055271
80 | *
81 | * So we register a one time worker to re init
82 | *
83 | */
84 | state?.let {
85 | //Set state
86 | networkState.value = it
87 | //Update updateDeprecatedIsOnline to reflect changes so that isDeviceOnline would return current value if and when networkState.value.isDeviceOnline() is false
88 | updateDeprecatedIsOnline(context)
89 | }
90 |
91 |
92 | }
93 |
94 |
95 | if (!skipDelayedStatusCheck) {
96 | /**
97 | * Check state of internetStateMonitor after delayedStartUpStatusCheckTimeInMs seconds to make sure it is running due to https://issuetracker.google.com/issues/175055271
98 | */
99 | Handler(Looper.getMainLooper()).postDelayed({
100 | if (internetStateMonitor == null) {
101 | if (CLog.isDebug()) {
102 | CLog.log(logTag, "start -> Delayed status check for InternetStateMonitor found that was InternetStateMonitor was NULL. Calling start() again")
103 | }
104 | start(context, true)
105 | } else {
106 | if (CLog.isDebug()) {
107 | CLog.log(logTag, "start -> Delayed status check for InternetStateMonitor calling ensureInternetStateMonitorIsRegistered() to make sure we are observing connectivity changes")
108 | }
109 | ensureInternetStateMonitorIsRegistered()
110 | }
111 | }, delayedStartUpStatusCheckTimeInMs)
112 | }
113 | }
114 |
115 | fun isDeviceOnline(): Boolean {
116 | /**
117 | * Getting networkState connectivity takes a little bit time and some functions use isDeviceOnline() as soon as app started.
118 | * This is the best way to make sure we get real connectivity result without waiting for networkState
119 | */
120 | val result = if (networkState.value.isDeviceOnline()) {
121 | true
122 | } else {
123 | deprecatedIsOnline
124 | }
125 | if (CLog.isDebug()) {
126 | CLog.log(logTag, "isDeviceOnline() -> $result")
127 | }
128 | return result
129 | }
130 |
131 | fun isConnectedViaWifi(): Boolean {
132 | val result = if (isDeviceOnline()) {
133 | internetStateMonitor?.isConnectionUnMetered() ?: false
134 | } else {
135 | false
136 | }
137 |
138 | if (CLog.isDebug()) {
139 | CLog.log(logTag, "isConnectedViaWifi() -> $result")
140 | }
141 | return result
142 | }
143 |
144 | fun networkStateFlow() = networkState.asStateFlow()
145 |
146 | /**
147 | * Uses only the latest networkState within provided waitMillis or 5 seconds if no waitMillis provided.
148 | * Sometimes we get updates quite quickly.
149 | * For example, putting device to airplane mode triggers network change with intern access true first then triggers onLost withing milliseconds.
150 | */
151 | fun networkStateFlowDelayed(waitMillis: Long = 1000) = networkStateFlow().extDebounce(waitMillis)
152 |
153 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/connectivity/NetworkState.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.connectivity
2 |
3 |
4 | data class NetworkState(
5 |
6 | private val hasInternetCapability: Boolean,
7 | /**
8 | * Determines if the network is metered. Such as Mobile Data
9 | * It also allows us to inform observers that actual data connection type changed (from wifi to mobile and vice versa) which is useful on SIP connections
10 | */
11 | private val isMetered: Boolean,
12 |
13 | private val isRoaming: Boolean,
14 |
15 | /**
16 | * To make sure we broadcast changes even if hasInternetCapability/isMetered not changed. Perhaps user has 2 sim cards or connected to vpn. networkHandle changes for each network including VPN
17 | */
18 | private val networkHandle: Long
19 | ) {
20 | fun isConnectionUnMetered() = isMetered.not()
21 | fun isDeviceOnline() = hasInternetCapability
22 | fun isRoaming() = isRoaming
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/debug/DebugLogActivity.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.debug
2 |
3 | import android.Manifest
4 | import android.content.ActivityNotFoundException
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.pm.PackageManager
8 | import android.os.Bundle
9 | import android.provider.Settings
10 | import android.widget.ArrayAdapter
11 | import android.widget.ListView
12 | import android.widget.Toast
13 | import androidx.activity.enableEdgeToEdge
14 | import androidx.activity.result.contract.ActivityResultContracts
15 | import androidx.appcompat.app.AppCompatActivity
16 | import androidx.core.content.ContextCompat
17 | import androidx.core.view.ViewCompat
18 | import androidx.core.view.WindowInsetsCompat
19 | import androidx.lifecycle.lifecycleScope
20 | import com.nll.store.App
21 | import com.nll.store.R
22 | import com.nll.store.databinding.ActivityDebugLogBinding
23 | import com.nll.store.utils.ApiLevel
24 | import kotlinx.coroutines.flow.launchIn
25 | import kotlinx.coroutines.flow.onEach
26 | import kotlinx.coroutines.launch
27 | import java.io.File
28 |
29 |
30 | class DebugLogActivity : AppCompatActivity() {
31 | private val logTag = "DebugLogActivity"
32 | private lateinit var binding: ActivityDebugLogBinding
33 | private var logList: MutableList = ArrayList()
34 | private var logAdapter: ArrayAdapter? = null
35 | private var notificationPermissionDenyCount = 0
36 | private val postNotificationPermission = activityResultRegistry.register("notification", ActivityResultContracts.RequestPermission()) { hasNotificationPermission ->
37 | if (hasNotificationPermission) {
38 | DebugLogService.startLogging(this)
39 | }
40 | }
41 |
42 | private fun hasNotificationPermission() = if (ApiLevel.isTPlus()) {
43 | ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
44 | } else {
45 | true
46 | }
47 |
48 | override fun onCreate(savedInstanceState: Bundle?) {
49 | enableEdgeToEdge()
50 | super.onCreate(savedInstanceState)
51 | binding = ActivityDebugLogBinding.inflate(layoutInflater)
52 | setContentView(binding.root)
53 | ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
54 | val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout())
55 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
56 | //WindowInsetsCompat.CONSUMED
57 | insets
58 | }
59 |
60 | DebugLogService.observableLogProxy().onEach {
61 |
62 | logList.add(it)
63 | logAdapter?.notifyDataSetChanged()
64 |
65 |
66 | if (!binding.clearButton.isEnabled) {
67 | binding.clearButton.isEnabled = true
68 | }
69 |
70 | if (!binding.sendButton.isEnabled) {
71 | binding.sendButton.isEnabled = true
72 | }
73 |
74 | }.launchIn(lifecycleScope)
75 |
76 | DebugLogService.serviceMessage().onEach { serviceMessage ->
77 | android.util.Log.d("STORE_$logTag", "serviceMessage -> $serviceMessage")
78 |
79 | when (serviceMessage) {
80 | is DebugLogServiceMessage.Saved -> {
81 | if (serviceMessage.success) {
82 | Toast.makeText(this, String.format(getString(R.string.debug_log_dumped), serviceMessage.path), Toast.LENGTH_LONG).show()
83 | val i = Intent(Intent.ACTION_SEND).apply {
84 | /**
85 | * is the MIME type of the data being sent.
86 | * getExtra can have either a EXTRA_TEXT or EXTRA_STREAM field, containing the data to be sent.
87 | * If using EXTRA_TEXT, the MIME type should be "text/plain";
88 | * otherwise it should be the MIME type of the data in EXTRA_STREAM.
89 | */
90 | type = "application/zip"
91 | putExtra(Intent.EXTRA_EMAIL, arrayOf(App.contactEmail))
92 | putExtra(Intent.EXTRA_SUBJECT, getString(R.string.debug_log))
93 | putExtra(Intent.EXTRA_STREAM, DebugLogAttachmentProvider.getAttachmentUri(false, File(serviceMessage.path!!)))
94 | }
95 | try {
96 | startActivity(Intent.createChooser(i, getString(R.string.share)))
97 | } catch (ex: ActivityNotFoundException) {
98 | Toast.makeText(this, R.string.no_url_handle, Toast.LENGTH_SHORT).show()
99 | }
100 | } else {
101 | Toast.makeText(this, "Unable to dump logcat!", Toast.LENGTH_LONG).show()
102 | }
103 | }
104 |
105 | is DebugLogServiceMessage.Started -> {
106 |
107 | binding.startButton.isEnabled = false
108 | binding.stopButton.isEnabled = true
109 | binding.clearButton.isEnabled = serviceMessage.currentLogs.isNotEmpty()
110 | binding.sendButton.isEnabled = serviceMessage.currentLogs.isNotEmpty()
111 |
112 | logList = ArrayList(serviceMessage.currentLogs)
113 | logAdapter = ArrayAdapter(this, R.layout.row_debug_log, logList)
114 | binding.logView.adapter = logAdapter
115 | binding.logView.transcriptMode = ListView.TRANSCRIPT_MODE_NORMAL
116 | if (logList.size > 0) {
117 | binding.logView.setSelection(logList.size - 1)
118 | }
119 |
120 | }
121 |
122 | DebugLogServiceMessage.Stopped -> {
123 | logList.clear()
124 | logAdapter?.notifyDataSetChanged()
125 | binding.startButton.isEnabled = true
126 | binding.stopButton.isEnabled = false
127 | binding.clearButton.isEnabled = false
128 | binding.sendButton.isEnabled = false
129 | }
130 | }
131 |
132 | }.launchIn(lifecycleScope)
133 |
134 |
135 |
136 | binding.startButton.setOnClickListener {
137 | if (ApiLevel.isTPlus() && !hasNotificationPermission()) {
138 | postNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
139 | Toast.makeText(this, R.string.notification_permission_required, Toast.LENGTH_SHORT).show()
140 |
141 | notificationPermissionDenyCount++
142 | if (notificationPermissionDenyCount > 2) {
143 |
144 | //Open notification settings
145 | Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
146 | putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
147 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
148 | }.let(::startActivity)
149 |
150 | }
151 | } else {
152 | DebugLogService.startLogging(this)
153 | }
154 |
155 |
156 | }
157 |
158 | binding.stopButton.setOnClickListener {
159 | lifecycleScope.launch {
160 | DebugLogService.stopLogging()
161 | }
162 | }
163 |
164 | binding.clearButton.setOnClickListener {
165 | logList.clear()
166 | logAdapter?.notifyDataSetChanged()
167 | lifecycleScope.launch {
168 | DebugLogService.clearLogs()
169 | }
170 | }
171 |
172 | binding.sendButton.setOnClickListener {
173 | lifecycleScope.launch {
174 | DebugLogService.saveLogs()
175 | }
176 | }
177 |
178 |
179 | }
180 |
181 | companion object {
182 | fun startUi(context: Context) {
183 | context.startActivity(Intent(context, DebugLogActivity::class.java))
184 | }
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/debug/DebugLogAttachmentProvider.java:
--------------------------------------------------------------------------------
1 | package com.nll.store.debug;
2 |
3 | import android.content.ContentProvider;
4 | import android.content.ContentValues;
5 | import android.content.Context;
6 | import android.content.UriMatcher;
7 | import android.database.Cursor;
8 | import android.database.MatrixCursor;
9 | import android.net.Uri;
10 | import android.os.ParcelFileDescriptor;
11 | import android.provider.OpenableColumns;
12 |
13 | import androidx.annotation.NonNull;
14 |
15 | import com.nll.store.log.CLog;
16 |
17 | import java.io.File;
18 | import java.io.FileNotFoundException;
19 |
20 | public class DebugLogAttachmentProvider extends ContentProvider {
21 | public static final int cache_code = 2;
22 | public static final String CACHE_LOG_PATH = "logs";
23 | private static final String TAG = "DebugLogAttachmentProvider";
24 | private static final String AUTHORITY = "com.nll.store.debug.DebugLogAttachmentProvider";
25 | public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY);
26 | private UriMatcher uriMatcher;
27 |
28 |
29 | public static Uri getAttachmentUri(boolean useFileUri, File file) {
30 | Uri uri;
31 | if (useFileUri) {
32 | uri = Uri.fromFile(file);
33 | } else {
34 | uri = DebugLogAttachmentProvider.CONTENT_URI.buildUpon().appendPath(DebugLogAttachmentProvider.CACHE_LOG_PATH).appendPath(file.getName()).build();
35 |
36 | }
37 | if (CLog.isDebug()) {
38 | CLog.log(TAG, "Attachment URI is: " + uri);
39 | }
40 | return uri;
41 |
42 | }
43 |
44 | public static File getLogPath(Context context) {
45 | File root = new File(context.getExternalFilesDir(null), "/" + CACHE_LOG_PATH + "/");
46 | if (!root.exists()) {
47 | root.mkdirs();
48 | }
49 | return root;
50 |
51 | }
52 |
53 | private static String[] copyOf(String[] original, int newLength) {
54 | final String[] result = new String[newLength];
55 | System.arraycopy(original, 0, result, 0, newLength);
56 | return result;
57 | }
58 |
59 | private static Object[] copyOf(Object[] original, int newLength) {
60 | final Object[] result = new Object[newLength];
61 | System.arraycopy(original, 0, result, 0, newLength);
62 | return result;
63 | }
64 |
65 | @Override
66 | public boolean onCreate() {
67 | uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
68 | uriMatcher.addURI(AUTHORITY, CACHE_LOG_PATH + "/*", cache_code);
69 |
70 | return true;
71 | }
72 |
73 | @Override
74 | public String getType(@NonNull Uri uri) {
75 | return "application/zip";
76 |
77 | }
78 |
79 | @Override
80 | public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
81 | if (CLog.isDebug()) {
82 | CLog.log(TAG, "Called with uri: " + uri);
83 | }
84 |
85 |
86 | // Check incoming Uri against the matcher
87 | switch (uriMatcher.match(uri)) {
88 | case cache_code:
89 | if (CLog.isDebug()) {
90 | CLog.log(TAG, "File to open is : " + uri.getLastPathSegment());}
91 | //return openAttachment(uri.getLastPathSegment());
92 | // Create & return a ParcelFileDescriptor pointing to the file
93 | // Note: I don't care what mode they ask for - they're only getting
94 | // read only
95 | return ParcelFileDescriptor.open(getLogFileUri(uri), ParcelFileDescriptor.MODE_READ_ONLY);
96 |
97 |
98 | // Otherwise unrecognised Uri
99 | default:
100 | if (CLog.isDebug()) {
101 | CLog.log(TAG, "Unsupported uri: '" + uri);
102 | }
103 | throw new FileNotFoundException("Unsupported uri: " + uri.toString());
104 | }
105 |
106 |
107 | }
108 |
109 | private File getLogFileUri(Uri uri) {
110 | File root = getLogPath(getContext());
111 | File file = new File(root, uri.getLastPathSegment());
112 | if (CLog.isDebug()) {
113 | CLog.log(TAG, "getLogFileUri " + file.getAbsolutePath());
114 | }
115 | return file;
116 | }
117 |
118 | private File getFileForUri(Uri uri) {
119 | return new File(getLogPath(getContext()), uri.getLastPathSegment());
120 | }
121 |
122 | @Override
123 | public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
124 | final File file = getFileForUri(uri);
125 | if (CLog.isDebug()) {
126 | CLog.log(TAG, "query file is " + file.getAbsolutePath());
127 | }
128 | if (projection == null) {
129 | projection = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
130 | }
131 |
132 | String[] cols = new String[projection.length];
133 | Object[] values = new Object[projection.length];
134 | int i = 0;
135 | for (String col : projection) {
136 | if (OpenableColumns.DISPLAY_NAME.equals(col)) {
137 | cols[i] = OpenableColumns.DISPLAY_NAME;
138 | values[i++] = file.getName();
139 | } else if (OpenableColumns.SIZE.equals(col)) {
140 | cols[i] = OpenableColumns.SIZE;
141 | values[i++] = file.length();
142 | }
143 | }
144 |
145 |
146 | cols = copyOf(cols, i);
147 | values = copyOf(values, i);
148 |
149 | final MatrixCursor cursor = new MatrixCursor(cols, 1);
150 | cursor.addRow(values);
151 | return cursor;
152 | }
153 | // //////////////////////////////////////////////////////////////
154 | // Not supported / used / required
155 | // //////////////////////////////////////////////////////////////
156 |
157 | @Override
158 | public int update(Uri uri, ContentValues contentvalues, String s, String[] as) {
159 | return 0;
160 | }
161 |
162 | @Override
163 | public int delete(Uri uri, String s, String[] as) {
164 | return 0;
165 | }
166 |
167 | @Override
168 | public Uri insert(Uri uri, ContentValues contentvalues) {
169 | return null;
170 | }
171 |
172 |
173 | }
174 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/debug/DebugLogServiceCommand.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.debug
2 |
3 | sealed class DebugLogServiceCommand {
4 |
5 | data object Stop : DebugLogServiceCommand()
6 | data object Save : DebugLogServiceCommand()
7 | data object Clear : DebugLogServiceCommand()
8 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/debug/DebugLogServiceMessage.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.debug
2 |
3 | import java.util.LinkedList
4 |
5 | sealed class DebugLogServiceMessage {
6 |
7 | data class Started(val currentLogs: LinkedList) : DebugLogServiceMessage()
8 | data object Stopped : DebugLogServiceMessage()
9 | data class Saved(val success: Boolean, val path: String?) : DebugLogServiceMessage()
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/debug/DebugNotification.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.debug
2 |
3 | import android.app.Notification
4 | import android.app.PendingIntent
5 | import android.content.Context
6 | import androidx.core.app.NotificationCompat
7 | import com.nll.store.R
8 | import com.nll.store.utils.ApiLevel
9 | import com.nll.store.utils.extGetThemeAttrColor
10 | import io.karn.notify.Notify
11 | import io.karn.notify.entities.Payload
12 | import com.google.android.material.R as MaterialResources
13 |
14 |
15 | object DebugNotification {
16 | private fun alertPayload(context: Context) = Payload.Alerts(
17 | channelKey = "debug-log-notification",
18 | lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC,
19 | channelName = context.getString(R.string.debug_log),
20 | channelDescription = context.getString(R.string.debug_log),
21 | channelImportance = Notify.IMPORTANCE_LOW,
22 | showBadge = false
23 |
24 | )
25 |
26 | fun getDebugEnabledNotification(context: Context, startIntent: PendingIntent): NotificationCompat.Builder {
27 | val alertPayload = alertPayload(context)
28 | val builder = Notify.with(context)
29 | .meta {
30 | cancelOnClick = false
31 | sticky = true
32 | clickIntent = startIntent
33 | group = "debug"
34 | }
35 | .alerting(alertPayload.channelKey) {
36 | lockScreenVisibility = alertPayload.lockScreenVisibility
37 | channelName = alertPayload.channelName
38 | channelDescription = alertPayload.channelDescription
39 | channelImportance = alertPayload.channelImportance
40 |
41 | }
42 | .header {
43 | icon = R.drawable.notification_debug
44 | color = context.extGetThemeAttrColor(MaterialResources.attr.colorPrimary)
45 | showTimestamp = true
46 | }
47 | .content {
48 | title = context.getString(R.string.debug_log)
49 |
50 | }
51 | .asBuilder()
52 | if (ApiLevel.isSPlus()) {
53 | builder.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
54 | }
55 | return builder
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/ApkSource.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.Context
4 | import java.io.InputStream
5 |
6 | interface ApkSource {
7 |
8 | fun getInputStream(context: Context): InputStream
9 |
10 | fun getLength(context: Context): Long
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/FileApkSource.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.Context
4 | import java.io.File
5 | import java.io.FileInputStream
6 |
7 |
8 | class FileApkSource(private val file: File) : ApkSource {
9 | override fun getInputStream(context: Context) = FileInputStream(file)
10 | override fun getLength(context: Context) = file.length()
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/FileDownloader.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageInfo
5 | import android.webkit.MimeTypeMap
6 | import com.nll.store.log.CLog
7 | import com.nll.store.model.StoreAppData
8 | import com.nll.store.utils.getPackageInfoFromApk
9 | import okhttp3.Request
10 | import java.io.File
11 | import java.net.HttpURLConnection
12 |
13 | class FileDownloader() {
14 | private val logTag = "FileDownloader"
15 | private val bufferLengthBytes = 1024 * 8
16 |
17 | /**
18 | * Double needed as calculation can go ver Int.Max
19 | */
20 | private fun calculatePercentage(obtained: Double, total: Double) = (obtained * 100 / total).toInt()
21 | fun download(context: Context, storeAppData: StoreAppData, targetFile: File, callback: Callback) {
22 | if (CLog.isDebug()) {
23 | CLog.log(logTag, "download() -> downloadUrl: ${storeAppData.downloadUrl}, targetFile: $targetFile")
24 | }
25 |
26 | callback.onStarted(storeAppData)
27 |
28 | if (targetFile.exists()) {
29 | if (CLog.isDebug()) {
30 | CLog.log(logTag, "download() -> targetFile was already downloaded. Deleting it")
31 | }
32 | targetFile.delete()
33 | }
34 |
35 | try {
36 | val request = Request.Builder().url(storeAppData.downloadUrl).build()
37 | val response = HttpProvider.provideOkHttpClient().newCall(request).execute()
38 | val body = response.body
39 | val responseCode = response.code
40 | if (responseCode >= HttpURLConnection.HTTP_OK &&
41 | responseCode < HttpURLConnection.HTTP_MULT_CHOICE
42 | ) {
43 | val length = body.contentLength()
44 | body.byteStream().apply {
45 | targetFile.outputStream().use { fileOut ->
46 | var bytesCopied = 0
47 | val buffer = ByteArray(bufferLengthBytes)
48 | var bytes = read(buffer)
49 | while (bytes >= 0) {
50 | fileOut.write(buffer, 0, bytes)
51 | bytesCopied += bytes
52 | bytes = read(buffer)
53 |
54 | val percent = calculatePercentage(bytesCopied.toDouble(), length.toDouble())
55 | if (CLog.isDebug()) {
56 | CLog.log(logTag, "download() -> percent: $percent, bytesCopied: $bytesCopied, length: $length")
57 | }
58 | callback.onProgress(storeAppData, percent, bytesCopied, length)
59 | }
60 | }
61 | if (CLog.isDebug()) {
62 | CLog.log(logTag, "download() -> Completed")
63 | }
64 |
65 | val packageInfo = targetFile.getPackageInfoFromApk(context.applicationContext)
66 | if (packageInfo != null) {
67 | if (CLog.isDebug()) {
68 | CLog.log(logTag, "download() -> Renaming completed. Emitting DownloadStatus.Completed")
69 | }
70 | callback.onCompleted(storeAppData, targetFile, packageInfo)
71 | } else {
72 | if (CLog.isDebug()) {
73 | CLog.log(logTag, "download() -> Target file was malformed! Delete it")
74 | }
75 | targetFile.delete()
76 | callback.onMalformedFileError(storeAppData)
77 | }
78 |
79 | }
80 | } else {
81 | if (CLog.isDebug()) {
82 | CLog.log(logTag, "download() -> Download error. responseCode: $responseCode")
83 | }
84 | callback.onServerError(storeAppData, responseCode)
85 | }
86 | } catch (e: Exception) {
87 | if (CLog.isDebug()) {
88 | CLog.logPrintStackTrace(e)
89 | }
90 | callback.onError(storeAppData, e)
91 | }
92 | }
93 |
94 | interface Callback {
95 | fun onStarted(storeAppData: StoreAppData)
96 | fun onProgress(storeAppData: StoreAppData, percent: Int, bytesCopied: Int, length: Long)
97 | fun onCompleted(storeAppData: StoreAppData, targetFile: File, packageInfo: PackageInfo)
98 | fun onError(storeAppData: StoreAppData, exception: Exception)
99 | fun onServerError(storeAppData: StoreAppData, responseCode: Int)
100 | fun onMalformedFileError(storeAppData: StoreAppData)
101 | }
102 |
103 | companion object {
104 | fun getDestinationFile(context: Context, storeAppData: StoreAppData): File {
105 | val extension = MimeTypeMap.getFileExtensionFromUrl(storeAppData.downloadUrl)
106 | val fileName = "${storeAppData.packageName}_${storeAppData.version}.$extension"
107 | return File(getBaseFolder(context), fileName)
108 | }
109 |
110 | fun getBaseFolder(context: Context): File {
111 | val childFolder = "apks"
112 |
113 | /**
114 | * Use getExternalFilesDir instead of externalCacheDir.
115 | * There have been instances where Android cleared cache dir while we are downloading the apk
116 | */
117 | val baseFolder = File(context.getExternalFilesDir(null), childFolder)
118 | if (!baseFolder.exists()) {
119 | baseFolder.mkdirs()
120 | }
121 | return baseFolder
122 | }
123 | }
124 |
125 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/HttpProvider.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import com.nll.store.BuildConfig
4 | import com.nll.store.log.CLog
5 | import okhttp3.OkHttpClient
6 | import okhttp3.Request
7 | import okhttp3.logging.HttpLoggingInterceptor
8 | import java.util.concurrent.TimeUnit
9 |
10 | object HttpProvider {
11 | private const val logTag = "HttpProvider"
12 | private const val connectionTimeoutMs: Long = 10000
13 | private const val readTimeoutMs: Long = 50000
14 | private const val writeTimeoutMs: Long = 50000
15 | private val client: OkHttpClient by lazy {
16 | val loggingInterceptor = HttpLoggingInterceptor { message ->
17 | if (CLog.isDebug()) {
18 | CLog.log(logTag, message)
19 | }
20 | }.apply {
21 | val logLevel = if (BuildConfig.DEBUG) {
22 | HttpLoggingInterceptor.Level.BODY
23 | } else {
24 | HttpLoggingInterceptor.Level.NONE
25 | }
26 | setLevel(logLevel)
27 | //Security
28 | //redactHeader("Authorization");
29 | //redactHeader("Cookie");
30 | }
31 |
32 | OkHttpClient().newBuilder()
33 | .addInterceptor(loggingInterceptor)
34 | .connectTimeout(connectionTimeoutMs, TimeUnit.MILLISECONDS)
35 | .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
36 | .writeTimeout(writeTimeoutMs, TimeUnit.MILLISECONDS).build()
37 | }
38 |
39 |
40 | fun provideOkHttpClient(): OkHttpClient {
41 | return client
42 | }
43 |
44 | fun provideRequestForOwnServer(url: String): Request.Builder {
45 | return Request.Builder()
46 | .header("User-Agent", "NLLStore")
47 | .header("Accept", "*/*")
48 | .url(url)
49 |
50 |
51 | }
52 |
53 | fun provideRequest(url: String): Request.Builder {
54 | return Request.Builder()
55 | .header("User-Agent", "NLLStore")
56 | .header("Accept", "*/*")
57 | .url(url)
58 |
59 | }
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/InstallationEventsReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.app.PendingIntent
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.IntentSender
8 | import android.content.pm.PackageInstaller
9 | import com.nll.store.log.CLog
10 | import com.nll.store.utils.ApiLevel
11 | import com.nll.store.utils.extGetParcelableExtra
12 |
13 | class InstallationEventsReceiver : BroadcastReceiver() {
14 | private val logTag ="InstallationEventsReceiver"
15 |
16 | companion object{
17 | private const val intentAction = "APP_INSTALLER_ACTION"
18 | private const val requestCode = 6541
19 | fun createIntentSender(context: Context): IntentSender {
20 | val intent = Intent(context, InstallationEventsReceiver::class.java).apply {
21 | action = intentAction
22 | //https://cs.android.com/android/platform/superproject/+/master:frameworks/base/packages/PackageInstaller/src/com/android/packageinstaller/InstallInstalling.java;drc=143db55921ddda50741c90e0f8258b77298e4a9f;l=365
23 | flags = Intent.FLAG_RECEIVER_FOREGROUND
24 | }
25 | val flags = if (ApiLevel.isSPlus()) {
26 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
27 | } else {
28 | PendingIntent.FLAG_UPDATE_CURRENT
29 | }
30 | val pendingIntent = PendingIntent.getBroadcast(context, requestCode, intent, flags)
31 | return pendingIntent.intentSender
32 | }
33 | }
34 |
35 | override fun onReceive(context: Context, intent: Intent?) {
36 | if (CLog.isDebug()) {
37 | CLog.log(logTag, "onReceive() -> intent $intent")
38 | }
39 | if (intent?.action != intentAction) {
40 | return
41 | }
42 |
43 | val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
44 | if (CLog.isDebug()) {
45 | CLog.log(logTag, "onReceive() -> sessionId $sessionId")
46 | }
47 |
48 | when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
49 | PackageInstaller.STATUS_PENDING_USER_ACTION -> {
50 | if (CLog.isDebug()) {
51 | CLog.log(logTag, "onReceive() -> status: STATUS_PENDING_USER_ACTION")
52 | }
53 | val confirmationIntent = intent.extGetParcelableExtra(Intent.EXTRA_INTENT)
54 | if (confirmationIntent != null) {
55 |
56 | // This app isn't privileged, so the user has to confirm the install.
57 | val wrapperIntent = confirmationIntent.apply {
58 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
59 | }
60 | context.startActivity(wrapperIntent)
61 | }
62 | }
63 |
64 | else -> {
65 |
66 | val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
67 | val otherPackageName = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME)
68 | val storagePath = intent.getStringExtra(PackageInstaller.EXTRA_STORAGE_PATH)
69 | if (CLog.isDebug()) {
70 | CLog.log(logTag, "onReceive() -> status: $status, message: $message, otherPackageName: $otherPackageName, storagePath: $storagePath")
71 | }
72 | when (val installResult = PackageInstallResult.fromStatusCode(status, message, otherPackageName, storagePath)) {
73 | is PackageInstallResult.Failure -> AppInstallManager.updateInstallState(InstallationState.Install.Completed(PackageInstallResult.Failure(installResult.cause)))
74 | PackageInstallResult.Success -> AppInstallManager.updateInstallState(InstallationState.Install.Completed(PackageInstallResult.Success))
75 | }
76 | }
77 | }
78 | }
79 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/InstallationState.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageInfo
5 | import com.nll.store.ApkAttachmentProvider
6 | import com.nll.store.model.StoreAppData
7 | import java.io.File
8 |
9 | sealed class InstallationState {
10 | sealed class Download(open val storeAppData: StoreAppData) : InstallationState() {
11 | data class Started(override val storeAppData: StoreAppData) : Download(storeAppData)
12 | data class Progress(override val storeAppData: StoreAppData, val percent: Int, val bytesCopied: Int, val totalBytes: Long) : Download(storeAppData)
13 | data class Completed(override val storeAppData: StoreAppData, val downloadedFile: File, val packageInfo: PackageInfo) : Download(storeAppData) {
14 | fun getContentUri(context: Context) = ApkAttachmentProvider.getUri(context, downloadedFile.name)
15 | }
16 |
17 | data class Error(override val storeAppData: StoreAppData, val message: Message) : Download(storeAppData) {
18 |
19 | sealed class Message {
20 | data class GenericError(val message: String) : Message()
21 | data class ServerError(val responseCode: Int) : Message()
22 | data object MalformedFile : Message()
23 | }
24 | }
25 | }
26 |
27 | sealed class Install : InstallationState() {
28 | data class ProgressData(
29 | val progress: Int = 0,
30 | val max: Int = 100,
31 | val isIndeterminate: Boolean = false
32 | )
33 |
34 | data object Started : Install()
35 | data class Progress(val progress: ProgressData) : Install()
36 | data class Completed(val installResult: PackageInstallResult) : Install()
37 |
38 |
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/PackageInstallFailureCause.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | /**
4 | * Represents the cause of installation failure. Contains string representation in [message] property.
5 | *
6 | * May be either [Generic], [Aborted], [Blocked], [Conflict], [Incompatible], [Invalid] or [Storage].
7 | * @property message Detailed string representation of the status, including raw details that are useful for debugging.
8 | */
9 | sealed class PackageInstallFailureCause(open val message: String?) {
10 |
11 | /**
12 | * The operation failed in a generic way. The system will always try to provide a more specific failure reason,
13 | * but in some rare cases this may be delivered.
14 | */
15 | data class Generic(override val message: String?) : PackageInstallFailureCause(message) {
16 | override fun toString(): String = message ?: "INSTALL_FAILURE"
17 | }
18 |
19 | /**
20 | * The operation failed because it was actively aborted.
21 | * For example, the user actively declined requested permissions, or the session was abandoned.
22 | */
23 | data class Aborted(override val message: String?) : PackageInstallFailureCause(message) {
24 | override fun toString(): String = message ?: "INSTALL_FAILURE_ABORTED"
25 | }
26 |
27 | /**
28 | * The operation failed because it was blocked. For example, a device policy may be blocking the operation,
29 | * a package verifier may have blocked the operation, or the app may be required for core system operation.
30 | *
31 | * The result may also contain [otherPackageName] with the specific package blocking the install.
32 | */
33 | data class Blocked(
34 | override val message: String?,
35 | val otherPackageName: String? = null
36 | ) : PackageInstallFailureCause(message) {
37 |
38 | override fun toString(): String = (message ?: "INSTALL_FAILURE_BLOCKED") +
39 | if (otherPackageName != null) " | OTHER_PACKAGE_NAME = $otherPackageName" else ""
40 | }
41 |
42 | /**
43 | * The operation failed because it conflicts (or is inconsistent with) with another package already installed
44 | * on the device. For example, an existing permission, incompatible certificates, etc. The user may be able to
45 | * uninstall another app to fix the issue.
46 | *
47 | * The result may also contain [otherPackageName] with the specific package identified as the cause of the conflict.
48 | */
49 | data class Conflict(
50 | override val message: String?,
51 | val otherPackageName: String? = null
52 | ) : PackageInstallFailureCause(message) {
53 |
54 | override fun toString(): String = "${message ?: "INSTALL_FAILURE_CONFLICT"}${if (otherPackageName != null) " | OTHER_PACKAGE_NAME = $otherPackageName" else ""}"
55 | }
56 |
57 | /**
58 | * The operation failed because it is fundamentally incompatible with this device. For example, the app may
59 | * require a hardware feature that doesn't exist, it may be missing native code for the ABIs supported by the
60 | * device, or it requires a newer SDK version, etc.
61 | */
62 | data class Incompatible(override val message: String?) : PackageInstallFailureCause(message) {
63 | override fun toString(): String = message ?: "INSTALL_FAILURE_INCOMPATIBLE"
64 | }
65 |
66 | /**
67 | * The operation failed because one or more of the APKs was invalid. For example, they might be malformed,
68 | * corrupt, incorrectly signed, mismatched, etc.
69 | */
70 | data class Invalid(override val message: String?) : PackageInstallFailureCause(message) {
71 | override fun toString(): String = message ?: "INSTALL_FAILURE_INVALID"
72 | }
73 |
74 | /**
75 | * The operation failed because of storage issues. For example, the device may be running low on space,
76 | * or external media may be unavailable. The user may be able to help free space or insert different external media.
77 | *
78 | * The result may also contain [storagePath] with the path to the storage device that caused the failure.
79 | */
80 | data class Storage(
81 | override val message: String?,
82 | val storagePath: String? = null
83 | ) : PackageInstallFailureCause(message) {
84 |
85 | override fun toString(): String = "${message ?: "INSTALL_FAILURE_STORAGE"}${if (storagePath != null) " | STORAGE_PATH = $storagePath" else ""}"
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/PackageInstallResult.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.pm.PackageInstaller
4 |
5 | sealed class PackageInstallResult {
6 |
7 | data object Success : PackageInstallResult()
8 |
9 | /**
10 | * Install failed.
11 | *
12 | * May contain cause of failure in [cause] property.
13 | * @property cause Cause of installation failure. Always null on Android versions lower than Lollipop (5.0).
14 | */
15 | data class Failure(val cause: PackageInstallFailureCause? = null) : PackageInstallResult() {
16 |
17 | override fun toString(): String = "INSTALL_FAILURE${if (cause != null) " | cause = $cause" else ""}"
18 | }
19 |
20 | companion object {
21 |
22 | /**
23 | * Converts Android's [PackageInstaller] status code to [InstallResult] object.
24 | */
25 |
26 | fun fromStatusCode(
27 | statusCode: Int,
28 | message: String? = null,
29 | otherPackageName: String? = null,
30 | storagePath: String? = null
31 | ): PackageInstallResult = when (statusCode) {
32 | PackageInstaller.STATUS_SUCCESS -> Success
33 | PackageInstaller.STATUS_FAILURE -> Failure(PackageInstallFailureCause.Generic(message))
34 | PackageInstaller.STATUS_FAILURE_ABORTED -> Failure(PackageInstallFailureCause.Aborted(message))
35 | PackageInstaller.STATUS_FAILURE_BLOCKED -> Failure(PackageInstallFailureCause.Blocked(message, otherPackageName))
36 | PackageInstaller.STATUS_FAILURE_CONFLICT -> Failure(PackageInstallFailureCause.Conflict(message, otherPackageName))
37 | PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> Failure(PackageInstallFailureCause.Incompatible(message))
38 | PackageInstaller.STATUS_FAILURE_INVALID -> Failure(PackageInstallFailureCause.Invalid(message))
39 | PackageInstaller.STATUS_FAILURE_STORAGE -> Failure(PackageInstallFailureCause.Storage(message, storagePath))
40 | else -> Failure()
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/installer/UriApkSource.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.installer
2 |
3 | import android.content.Context
4 | import android.net.Uri
5 |
6 |
7 | class UriApkSource(private val uri: Uri) : ApkSource {
8 | /**
9 | * We expect openInputStream return not null because we already check if we have read access when passing uri to installer
10 | */
11 | override fun getInputStream(context: Context) = context.contentResolver.openInputStream(uri)!!
12 |
13 | /**
14 | * We expect openInputStream return not null because we already check if we have read access when passing uri to installer
15 | */
16 | override fun getLength(context: Context) = context.contentResolver.openFileDescriptor(uri, "r").use { it!!.statSize }
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/log/CLog.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.log
2 |
3 | import android.content.Context
4 | import com.nll.store.BuildConfig
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.SupervisorJob
8 | import kotlinx.coroutines.flow.MutableSharedFlow
9 | import kotlinx.coroutines.flow.asSharedFlow
10 | import kotlinx.coroutines.launch
11 | import java.text.SimpleDateFormat
12 | import java.util.Locale
13 |
14 | object CLog {
15 | private const val logTag = "AppLog"
16 |
17 | private val loggerDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT)
18 | private val loggerScope: CoroutineScope by lazy { CoroutineScope(Dispatchers.Main + SupervisorJob()) }
19 | private var _debug = BuildConfig.DEBUG
20 | private val _observableLog = MutableSharedFlow()
21 | fun observableLog() = _observableLog.asSharedFlow()
22 |
23 | @JvmStatic
24 | fun isDebug() = _debug
25 | fun disableDebug() {
26 | _debug = false
27 | }
28 |
29 | @JvmStatic
30 | fun enableDebug(context: Context? = null) {
31 | _debug = true
32 | }
33 |
34 | @JvmStatic
35 | fun logPrintStackTrace(e: Throwable) {
36 | //We do not want to print stack trace in to log if it is debug build. This would create douple printing of stack traces to logcat
37 | val shouldLog = isDebug() && !BuildConfig.DEBUG
38 | if (shouldLog) {
39 | log(logTag, e.stackTraceToString())
40 | }
41 | e.printStackTrace()
42 | }
43 |
44 | @JvmStatic
45 | fun log(extraTag: String, message: String) {
46 | android.util.Log.d("STORE_$extraTag", message)
47 | loggerScope.launch {
48 | _observableLog.emit("[${loggerDateFormat.format(System.currentTimeMillis())}] [STORE_$extraTag] => $message")
49 | }
50 |
51 | }
52 |
53 | @JvmStatic
54 | fun logAsInfo(extraTag: String, message: String) {
55 | android.util.Log.i("STORE_$extraTag", message)
56 | loggerScope.launch {
57 | _observableLog.emit("[${loggerDateFormat.format(System.currentTimeMillis())}] [STORE_$extraTag] => $message")
58 | }
59 |
60 | }
61 |
62 |
63 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/model/AppData.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.model
2 |
3 |
4 | import android.widget.ImageView
5 | import com.nll.store.BuildConfig
6 |
7 | data class AppData(val storeAppData: StoreAppData, val appInstallState: AppInstallState) {
8 |
9 |
10 | fun getId() = storeAppData.getId()
11 |
12 | fun loadIcon(imageView: ImageView) {
13 |
14 | when (appInstallState) {
15 | is AppInstallState.Installed -> imageView.setImageDrawable(appInstallState.localAppData.icon)
16 | AppInstallState.NotInstalled -> storeAppData.loadLogo(imageView)
17 |
18 | }
19 | }
20 |
21 | fun canBeUpdated() = when (appInstallState) {
22 | is AppInstallState.Installed -> storeAppData.version > appInstallState.localAppData.versionCode
23 | AppInstallState.NotInstalled -> false
24 |
25 | }
26 |
27 | fun isSelf() = storeAppData.packageName == BuildConfig.APPLICATION_ID
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/model/AppInstallState.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.model
2 |
3 | sealed class AppInstallState {
4 | data object NotInstalled: AppInstallState()
5 | data class Installed(val localAppData: LocalAppData): AppInstallState()
6 |
7 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/model/LocalAppData.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.model
2 |
3 | import android.graphics.drawable.Drawable
4 |
5 | data class LocalAppData(val id: Int, val icon: Drawable, val name: String, val packageName: String, val versionCode: Long)
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/model/StoreAppData.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.model
2 |
3 | import android.widget.ImageView
4 | import coil.imageLoader
5 | import coil.request.ImageRequest
6 | import com.nll.store.R
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 |
10 | @Serializable
11 | data class StoreAppData(
12 | @SerialName("isNLLStoreApp") val isNLLStoreApp: Boolean,
13 | @SerialName("name") val name: String,
14 | @SerialName("packageName") val packageName: String,
15 | @SerialName("version") val version: Long,
16 | @SerialName("downloadUrl") val downloadUrl: String,
17 | @SerialName("autoUpdate") val autoUpdate: Boolean,
18 | @SerialName("logoUrl") val logoUrl: String,
19 | @SerialName("description") val description: String,
20 | @SerialName("versionNotes") val versionNotes: String,
21 | @SerialName("website") val website: String
22 | ){
23 | fun getId() = packageName.hashCode()
24 |
25 | fun loadLogo(imageView: ImageView){
26 | val request = ImageRequest.Builder(imageView.context)
27 | .placeholder(R.drawable.ic_place_holder_24dp)
28 | .data(logoUrl)
29 | .crossfade(false)
30 | .target(imageView)
31 | .build()
32 | imageView.context.imageLoader.enqueue(request)
33 | }
34 | }
35 |
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/model/StoreConnectionState.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.model
2 |
3 | import com.nll.store.api.ApiException
4 |
5 | sealed class StoreConnectionState {
6 | data object Connecting : StoreConnectionState()
7 | data object Connected : StoreConnectionState()
8 | data class Failed(val apiException: ApiException) : StoreConnectionState()
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/ui/AppListViewHolder.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.ui
2 |
3 | import androidx.recyclerview.widget.RecyclerView
4 | import com.nll.store.R
5 | import com.nll.store.databinding.RowAppItemBinding
6 | import com.nll.store.log.CLog
7 | import com.nll.store.model.AppData
8 | import com.nll.store.model.AppInstallState
9 |
10 | class AppListViewHolder(private val binding: RowAppItemBinding) : RecyclerView.ViewHolder(binding.root) {
11 | private val logTag = "AppListViewHolder"
12 | fun bind(data: AppData, callback: AppsListAdapter.CallBack, position: Int) {
13 | if (CLog.isDebug()) {
14 | CLog.log(logTag, "bind() -> data: $data")
15 | }
16 |
17 | binding.appInfo.setOnClickListener {
18 | callback.onCardClick(data, position)
19 | }
20 |
21 | //Do not allow clicking on action button if this is actual NLL Store app and does not need updating
22 | binding.appActionButton.isEnabled = data.canBeUpdated() || !data.isSelf()
23 | binding.appActionButton.setOnClickListener {
24 |
25 | when (data.appInstallState) {
26 | is AppInstallState.Installed -> {
27 | if (data.canBeUpdated()) {
28 | callback.onUpdateClick(data.storeAppData, data.appInstallState.localAppData, position)
29 | } else {
30 | callback.onOpenClick(data.appInstallState.localAppData, position)
31 | }
32 | }
33 |
34 | AppInstallState.NotInstalled -> callback.onInstallClick(data.storeAppData, position)
35 | }
36 |
37 | }
38 |
39 | data.loadIcon(binding.appIcon)
40 | binding.appName.text = data.storeAppData.name
41 | binding.appDescription.text = data.storeAppData.description
42 | binding.appActionButton.text = when (data.appInstallState) {
43 |
44 | is AppInstallState.Installed -> {
45 | if (data.canBeUpdated()) {
46 | binding.root.context.getString(R.string.update)
47 | } else {
48 | binding.root.context.getString(R.string.open)
49 | }
50 | }
51 |
52 | AppInstallState.NotInstalled -> binding.root.context.getString(R.string.install)
53 | }
54 |
55 |
56 | }
57 |
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/ui/AppsListAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.ui
2 |
3 | import android.view.LayoutInflater
4 | import android.view.ViewGroup
5 | import androidx.recyclerview.widget.DiffUtil
6 | import androidx.recyclerview.widget.ListAdapter
7 | import com.nll.store.databinding.RowAppItemBinding
8 | import com.nll.store.model.AppData
9 | import com.nll.store.model.LocalAppData
10 | import com.nll.store.model.StoreAppData
11 |
12 |
13 | class AppsListAdapter(private val callback: CallBack) : ListAdapter(DiffCallback) {
14 | private val logTag = "AppsListAdapter"
15 | override fun onBindViewHolder(holder: AppListViewHolder, position: Int) {
16 | holder.bind(getItem(position), callback, position)
17 |
18 | }
19 |
20 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppListViewHolder {
21 | return AppListViewHolder(RowAppItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
22 | }
23 |
24 | interface CallBack {
25 | fun onCardClick(data: AppData, position: Int)
26 | fun onInstallClick(storeAppData: StoreAppData, position: Int)
27 | fun onOpenClick(localAppData: LocalAppData, position: Int)
28 | fun onUpdateClick(storeAppData: StoreAppData, localAppData: LocalAppData, position: Int)
29 | }
30 |
31 | object DiffCallback : DiffUtil.ItemCallback() {
32 | override fun areItemsTheSame(oldItem: AppData, newItem: AppData): Boolean {
33 | return oldItem.getId() == newItem.getId()
34 | }
35 |
36 | override fun areContentsTheSame(oldItem: AppData, newItem: AppData): Boolean {
37 | return oldItem == newItem
38 | }
39 |
40 |
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/ui/InputStream.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.ui
2 |
3 | import okio.Buffer
4 | import okio.buffer
5 | import okio.sink
6 | import okio.source
7 | import java.io.InputStream
8 | import java.io.OutputStream
9 | import kotlin.math.ceil
10 |
11 | inline fun InputStream.extCopyTo(outputStream: OutputStream, totalSize: Long, progressOffsetBytes: Long = 0, crossinline onProgressChanged: (progress: Int, max: Int) -> Unit) {
12 | val bufferLength = 8192L
13 | val progressRatio = ceil(totalSize.toDouble() / (bufferLength.coerceAtLeast(1) * 100)).toInt().coerceAtLeast(1)
14 | val progressOffset = progressOffsetBytes / bufferLength
15 | source().buffer().use { source ->
16 | outputStream.sink().buffer().use { sink ->
17 | val progressMax = ceil(totalSize.toDouble() / (bufferLength * progressRatio))
18 | .toInt()
19 | .coerceAtLeast(1)
20 | var currentProgress = progressOffset
21 | Buffer().use { buffer ->
22 | while (source.read(buffer, bufferLength) > 0) {
23 | sink.write(buffer, buffer.size)
24 | currentProgress++
25 | if (currentProgress % progressRatio == 0L) {
26 | val progress = (currentProgress.toDouble() / (progressRatio * progressMax) * 100).toInt()
27 | onProgressChanged(progress, 100)
28 | }
29 | }
30 | sink.flush()
31 | }
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/ui/SnackProvider.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.ui
2 |
3 | import android.view.View
4 | import com.google.android.material.snackbar.Snackbar
5 |
6 | object SnackProvider {
7 |
8 | fun provideDefaultSnack(root: View, anchorView: View? = null, snackText: String, snackActionText: String? = null, durationMilliSeconds: Int = Snackbar.LENGTH_INDEFINITE, snackClickListener: ClickListener): Snackbar {
9 | return getBaseSnack(root, anchorView, snackText, snackActionText, durationMilliSeconds, snackClickListener)
10 |
11 | }
12 |
13 | private fun getBaseSnack(root: View, anchorView: View?, snackText: String, snackActionText: String?, duration: Int, snackClickListener: ClickListener): Snackbar {
14 | val snack = Snackbar.make(root, snackText, duration).apply {
15 | view.setOnClickListener {
16 | snackClickListener.onSnackViewClick()
17 | dismiss()
18 | }
19 | snackActionText?.let {
20 | setAction(snackActionText) {
21 | snackClickListener.onActionClick()
22 | }
23 | }
24 | }
25 | anchorView?.let {
26 | snack.setAnchorView(it)
27 | }
28 | return snack
29 | }
30 |
31 |
32 | interface ClickListener {
33 | fun onActionClick()
34 | fun onSnackViewClick()
35 | }
36 |
37 | //When we don't need to handle Action Click
38 | fun interface ViewClickListener : ClickListener {
39 | override fun onActionClick() {
40 | //unused
41 | }
42 |
43 | }
44 |
45 | //When we don't need to handle View Click
46 | fun interface ActionClickListener : ClickListener {
47 | override fun onSnackViewClick() {
48 | //unused
49 | }
50 |
51 | }
52 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/update/PeriodicUpdateCheckWorker.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.update
2 |
3 | import android.content.Context
4 | import androidx.work.Configuration
5 | import androidx.work.Constraints
6 | import androidx.work.CoroutineWorker
7 | import androidx.work.ExistingPeriodicWorkPolicy
8 | import androidx.work.NetworkType
9 | import androidx.work.PeriodicWorkRequest
10 | import androidx.work.WorkManager
11 | import androidx.work.WorkerParameters
12 | import com.nll.store.api.StoreApiManager
13 | import com.nll.store.log.CLog
14 | import java.text.DateFormat
15 | import java.util.concurrent.TimeUnit
16 |
17 | class PeriodicUpdateCheckWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
18 |
19 | override suspend fun doWork(): Result {
20 | if (CLog.isDebug()) {
21 | CLog.log(logTag, "PeriodicUpdateCheckWorker run @ ${DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(System.currentTimeMillis())}")
22 | }
23 |
24 | StoreApiManager.getInstance(applicationContext).checkUpdates()
25 |
26 | return Result.success()
27 | }
28 |
29 | companion object{
30 | private val logTag = "PeriodicUpdateCheckWorker"
31 | fun enqueueUpdateCheck(context: Context){
32 |
33 | /**
34 | * Prevent java.lang.IllegalStateException: WorkManager is not initialized properly
35 | * We somehow get here while WorkManager is not initialized since targeting Android 14
36 | * TODO May be investigate later
37 | */
38 | val isWorkManagerInitialized = WorkManager.isInitialized()
39 | if (CLog.isDebug()) {
40 | CLog.log(logTag, "isWorkManagerInitialized: $isWorkManagerInitialized")
41 | }
42 | if(!isWorkManagerInitialized){
43 | WorkManager.initialize(context, Configuration.Builder().build())
44 | }
45 |
46 |
47 |
48 | val tag = "periodic-update-check"
49 | val constraints = Constraints.Builder()
50 | .setRequiredNetworkType(NetworkType.CONNECTED)
51 | .build()
52 |
53 | val periodicDeleteWorkRequest = PeriodicWorkRequest.Builder(PeriodicUpdateCheckWorker::class.java, 24, TimeUnit.HOURS).apply {
54 | addTag(tag)
55 | setConstraints(constraints)
56 | setInitialDelay(24, TimeUnit.HOURS)
57 | }.build()
58 |
59 |
60 |
61 | WorkManager.getInstance(context.applicationContext).enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.KEEP, periodicDeleteWorkRequest)
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/update/UpdateNotification.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.update
2 |
3 | import android.app.PendingIntent
4 | import android.content.Context
5 | import android.content.Intent
6 | import androidx.appcompat.view.ContextThemeWrapper
7 | import androidx.core.app.NotificationCompat
8 | import com.nll.store.R
9 | import com.nll.store.ui.AppListActivity
10 | import com.nll.store.utils.extGetThemeAttrColor
11 | import io.karn.notify.Notify
12 | import io.karn.notify.entities.Payload
13 |
14 | object UpdateNotification {
15 | private fun alertPayload(context: Context) = Payload.Alerts(
16 | channelKey = "update-notification",
17 | lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC,
18 | channelName = context.getString(R.string.update),
19 | channelDescription = context.getString(R.string.update),
20 | channelImportance = Notify.IMPORTANCE_NORMAL,
21 | showBadge = false
22 |
23 | )
24 |
25 | fun postUpdateNotification(context: Context) {
26 | val notificationColor = ContextThemeWrapper(context.applicationContext, R.style.AppTheme).extGetThemeAttrColor(com.google.android.material.R.attr.colorPrimary)
27 | val startIntent = Intent(context, AppListActivity::class.java).apply {
28 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
29 | }
30 | val startPendingIntent = PendingIntent.getActivity(context, 0, startIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
31 | val alertPayload = alertPayload(context)
32 | Notify.with(context)
33 | .meta {
34 | cancelOnClick = true
35 | sticky = false
36 | clickIntent = startPendingIntent
37 | group = "update"
38 | }
39 | .alerting(alertPayload.channelKey) {
40 | lockScreenVisibility = alertPayload.lockScreenVisibility
41 | channelName = alertPayload.channelName
42 | channelDescription = alertPayload.channelDescription
43 | channelImportance = alertPayload.channelImportance
44 |
45 | }
46 | .header {
47 | icon = R.drawable.ic_update_found
48 | color = notificationColor
49 | colorized = true
50 | showTimestamp = true
51 | }
52 | .content {
53 | title = context.getString(R.string.update_found)
54 |
55 | }
56 | .show("update".hashCode())
57 |
58 |
59 | }
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/ApiLevel.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import android.os.Build
4 | import androidx.annotation.ChecksSdkIntAtLeast
5 |
6 | object ApiLevel {
7 |
8 | /**
9 | * Api Level 28+, Android 9
10 | */
11 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
12 | fun isPiePlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
13 | /**
14 | * Api Level 29+, Android 10
15 | */
16 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
17 | fun isQPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
18 | /**
19 | * Api Level 30+, Android 11
20 | */
21 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
22 | fun isRPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
23 | /**
24 | * Api Level 31+, Android 12
25 | */
26 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
27 | fun isSPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
28 |
29 | /**
30 | * Api Level 32+, Android 12L
31 | */
32 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S_V2)
33 | fun isSV2Plus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2
34 |
35 | /**
36 | * Api Level 33+, Android 13
37 | */
38 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
39 | fun isTPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
40 |
41 | /**
42 | * Api Level 34+, Android 14
43 | */
44 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
45 | fun isUpsideDownCakePlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
46 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/Context.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.ConnectivityManager
6 | import android.widget.Toast
7 | import androidx.annotation.AttrRes
8 | import androidx.annotation.ColorInt
9 | import androidx.core.content.getSystemService
10 | import com.nll.store.log.CLog
11 |
12 | fun Context.extConnectivityManager(): ConnectivityManager? = getSystemService()
13 | /**
14 | * If @param errorMessage is provided, a toast message with provided be shown on failure to start activity
15 | */
16 | fun Context.extTryStartActivity(intent: Intent, errorMessage: String? = null) {
17 | try {
18 | if (intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK == 0) {
19 | if (CLog.isDebug()) {
20 | CLog.log("Context.extTryStartActivity", "FLAG_ACTIVITY_NEW_TASK was not added! Adding it")
21 | }
22 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
23 | }
24 | startActivity(intent)
25 | } catch (e: Exception) {
26 | errorMessage?.let {
27 | Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show()
28 | }
29 | CLog.logPrintStackTrace(e)
30 | }
31 | }
32 | @ColorInt
33 | fun Context.extGetThemeAttrColor(@AttrRes colorAttr: Int): Int {
34 | val array = obtainStyledAttributes(null, intArrayOf(colorAttr))
35 | return try {
36 | array.getColor(0, 0)
37 | } finally {
38 | array.recycle()
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/CoroutineScopeFactory.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Job
5 | import kotlin.coroutines.CoroutineContext
6 |
7 | object CoroutineScopeFactory {
8 |
9 | fun create(context: CoroutineContext): CoroutineScope = ContextScope(if (context[Job] != null) context else context + Job())
10 |
11 | internal class ContextScope(context: CoroutineContext) : CoroutineScope {
12 | override val coroutineContext = context
13 | override fun toString(): String = "CoroutineScopeFactory(coroutineContext=$coroutineContext)"
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/File.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import android.content.Context
4 | import android.content.pm.PackageInfo
5 | import com.nll.store.log.CLog
6 | import java.io.File
7 |
8 | fun File.getPackageInfoFromApk(context: Context): PackageInfo? {
9 | return try {
10 | context.packageManager.getPackageArchiveInfo(absolutePath, 0)
11 | } catch (e: Exception) {
12 | CLog.logPrintStackTrace(e)
13 | null
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/Flow.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import kotlinx.coroutines.Deferred
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.async
6 | import kotlinx.coroutines.delay
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.channelFlow
9 | import kotlinx.coroutines.launch
10 |
11 | /**
12 | * Kotlin's native debounce() and sample() does not meet our needs.
13 | * We need a debounce where it always emits the latest value after set millis seconds
14 | * debounce() -> Does not emit anything as long as the original flow emits items faster than every timeoutMillis milliseconds. So we do not get the latest value if network changes faster than milliseconds we use
15 | * sample() -> The latest element is not emitted if it does not fit into the sampling window. So we do not get the latest value if network changes faster than milliseconds we use
16 | */
17 | fun Flow.extDebounce(waitMillis: Long) = channelFlow {
18 | /*if (CLog.isDebug()) {
19 | CLog.log(logTag, "extDebounce() -> Thread is ${Thread.currentThread()}")
20 | }*/
21 |
22 | launch(Dispatchers.IO) {
23 |
24 | /*if (CLog.isDebug()) {
25 | CLog.log(logTag, "extDebounce() -> launch -> Thread is ${Thread.currentThread()}")
26 | }*/
27 |
28 | var delayPost: Deferred? = null
29 |
30 | collect {
31 |
32 | /* if (CLog.isDebug()) {
33 | CLog.log(logTag, "extDebounce() -> collect: $it -> Thread is ${Thread.currentThread()}")
34 | }*/
35 |
36 | delayPost?.cancel()
37 | delayPost = async {
38 |
39 | /*if (CLog.isDebug()) {
40 | CLog.log(logTag, "extDebounce() -> async() -> Thread is ${Thread.currentThread()}")
41 | }*/
42 |
43 | delay(waitMillis)
44 |
45 | /*if (CLog.isDebug()) {
46 | CLog.log(logTag, "extDebounce() -> send($it) -> Thread is ${Thread.currentThread()}")
47 | }*/
48 |
49 | send(it)
50 | }
51 | }
52 | }
53 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/Int.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import java.util.Locale
4 | import kotlin.math.ln
5 | import kotlin.math.pow
6 |
7 | fun Int.extHumanReadableByteCount(si: Boolean): String {
8 | val unit = if (si) 1000 else 1024
9 | if (this < unit)
10 | return "$this B"
11 | val exp = (ln(this.toDouble()) / ln(unit.toDouble())).toInt()
12 | val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
13 | return String.format(Locale.getDefault(), "%.1f %sB", this / unit.toDouble().pow(exp.toDouble()), pre)
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/Intent.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import android.content.Intent
4 | import android.os.Parcelable
5 | import java.io.Serializable
6 |
7 | inline fun Intent.extGetParcelableExtra(name: String?): T? {
8 | //Wait for https://issuetracker.google.com/issues/240585930
9 | //return extGetParcelableExtra(name, T::class.java)
10 |
11 | return getParcelableExtra(name)
12 | }
13 |
14 | @Suppress("DEPRECATION")
15 | fun Intent.extGetParcelableExtra(name: String?, clazz: Class): T? {
16 | return if (ApiLevel.isTPlus()) {
17 | getParcelableExtra(name, clazz)
18 | } else {
19 | getParcelableExtra(name)
20 | }
21 | }
22 |
23 |
24 | inline fun Intent.extGetSerializableExtra(name: String?): T? {
25 | //Wait for https://issuetracker.google.com/issues/240585930
26 | //return extGetSerializableExtra(name, T::class.java)
27 |
28 | return getSerializableExtra(name) as? T?
29 | }
30 |
31 | @Suppress("DEPRECATION", "UNCHECKED_CAST")
32 | fun Intent.extGetSerializableExtra(name: String?, clazz: Class): T? {
33 | return if (ApiLevel.isTPlus()) {
34 | getSerializableExtra(name, clazz)
35 | } else {
36 | getSerializableExtra(name) as? T?
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/Long.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import java.util.Locale
4 | import kotlin.math.ln
5 | import kotlin.math.pow
6 |
7 | fun Long.extHumanReadableByteCount(si: Boolean): String {
8 | val unit = if (si) 1000 else 1024
9 | if (this < unit)
10 | return "$this B"
11 | val exp = (ln(this.toDouble()) / ln(unit.toDouble())).toInt()
12 | val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
13 | return String.format(Locale.getDefault(), "%.1f %sB", this / unit.toDouble().pow(exp.toDouble()), pre)
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/PackageManager.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | import android.content.pm.ApplicationInfo
4 | import android.content.pm.PackageManager
5 |
6 | fun PackageManager.getInstalledApplicationsCompat(flags: Int): List {
7 | return if (ApiLevel.isTPlus()) {
8 | getInstalledApplications(PackageManager.ApplicationInfoFlags.of(flags.toLong()))
9 | } else {
10 | getInstalledApplications(flags)
11 | }
12 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nll/store/utils/SingletonHolder.kt:
--------------------------------------------------------------------------------
1 | package com.nll.store.utils
2 |
3 | open class SingletonHolder(creator: (A) -> T) {
4 | private var creator: ((A) -> T)? = creator
5 | @Volatile private var instance: T? = null
6 |
7 | fun getInstance(arg: A): T {
8 | val i = instance
9 | if (i != null) {
10 | return i
11 | }
12 |
13 | return synchronized(this) {
14 | val i2 = instance
15 | if (i2 != null) {
16 | i2
17 | } else {
18 | val created = requireNotNull(creator)(arg)
19 | instance = created
20 | creator = null
21 | created
22 | }
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/main/res/anim/fullscreen_dialog_slide_in_bottom.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/anim/fullscreen_dialog_slide_out_bottom.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/app_list_divider.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/crash_log_discard.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/crash_log_send.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_back_24.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_install.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
16 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_place_holder_24dp.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_update_found.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/notification_debug.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_app_list.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
19 |
20 |
30 |
31 |
32 |
33 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_debug_log.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
17 |
18 |
19 |
27 |
28 |
35 |
36 |
45 |
46 |
55 |
56 |
57 |
66 |
67 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_app_installer.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
45 |
46 |
55 |
56 |
62 |
63 |
67 |
68 |
76 |
77 |
88 |
89 |
97 |
98 |
99 |
100 |
109 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/row_app_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
20 |
21 |
29 |
30 |
43 |
44 |
55 |
56 |
57 |
58 |
64 |
65 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/row_debug_log.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/app_list_activity_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NLLAPPS/NLLStore/1f59b652e09e5a7405112c1e981a204bbfc538d3/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #ff0000
4 | #C00100
5 | #FFFFFF
6 | #FFDAD4
7 | #410000
8 | #775651
9 | #FFFFFF
10 | #FFDAD4
11 | #2C1512
12 | #705C2E
13 | #FFFFFF
14 | #FBDFA6
15 | #251A00
16 | #BA1A1A
17 | #FFDAD6
18 | #FFFFFF
19 | #410002
20 | #FFFBFF
21 | #201A19
22 | #FFFBFF
23 | #201A19
24 | #F5DDDA
25 | #534341
26 | #857370
27 | #FBEEEC
28 | #362F2E
29 | #FFB4A8
30 | #000000
31 | #C00100
32 | #D8C2BE
33 | #000000
34 | #FFB4A8
35 | #690100
36 | #930100
37 | #FFDAD4
38 | #E7BDB6
39 | #442925
40 | #5D3F3B
41 | #FFDAD4
42 | #DEC48C
43 | #3E2E04
44 | #564419
45 | #FBDFA6
46 | #FFB4AB
47 | #93000A
48 | #690005
49 | #FFDAD6
50 | #201A19
51 | #EDE0DD
52 | #201A19
53 | #EDE0DD
54 | #534341
55 | #D8C2BE
56 | #A08C89
57 | #201A19
58 | #EDE0DD
59 | #C00100
60 | #000000
61 | #FFB4A8
62 | #534341
63 | #000000
64 |
65 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 16dp
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values/fullscreen_dialog_slide_animation_constants.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 200
4 | 200
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | …
3 | NLL Store
4 |
5 | Please wait ongoing installation to finish
6 | App icon
7 | Internet connection required
8 | Install
9 | Error: %1$s
10 | Retry
11 | Unknown error
12 | Open
13 | Update
14 | Downloading
15 | Downloaded
16 | Permission denied
17 | Please grant app install permission
18 | Tap on install to continue installation
19 | Installing
20 | Download failed! Try downloading manually and then use menu above to install again
21 | Download
22 | Cannot find any app to open this link
23 | Back
24 | Malformed file
25 | Server error (%1$s)
26 | Installation failed!
27 | OK
28 | The operation failed because it was actively aborted. For example, the user actively declined requested permissions, or the session was abandoned
29 | The operation failed because it was blocked. For example, a device policy may be blocking the operation, a package verifier may have blocked the operation, or the app may be required for core system operation
30 | The operation failed because it conflicts (or is inconsistent with) with another package already installed on the device. For example, an existing permission, incompatible certificates, etc. The user may be able to uninstall another app to fix the issue
31 | The operation failed because it is fundamentally incompatible with this device. For example, the app may require a hardware feature that doesn\'t exist, it may be missing native code for the ABIs supported by the device, or it requires a newer SDK version, etc.
32 | The operation failed because one or more of the APKs was invalid. For example, they might be malformed, corrupt, incorrectly signed, mismatched, etc.
33 | The operation failed because of storage issues. For example, the device may be running low on space, or external media may be unavailable. The user may be able to help free space or insert different external media
34 | Installation cancelled
35 | Debug log
36 | Debug logs dumped to %1$s
37 | Share
38 | Start
39 | Stop
40 | Clear
41 | Send
42 | NLL Store has crashed
43 | There was an error causing NLL Store to crash and stop.\nPlease help us fix this by sending us error data, all you have to is tap \'OK\' and send this report by email.
44 | All permissions are required for full functionality
45 | Permission Request
46 | Cancel
47 | Crash notification
48 | Notification permission is required
49 | New updates found
50 | Unable to open this file. Please try again
51 | Installation completed
52 | Install blocked by device admin
53 |
--------------------------------------------------------------------------------
/app/src/main/res/values/style.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
17 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
35 |
36 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | buildscript {
4 |
5 | ext {
6 | commonValues = [
7 | appBaseNameSpace : 'com.nll.store',
8 | releaseMinifyEnabled : true,
9 | debugMinifyEnabled : true,
10 | releaseShrinkResourcesEnabled: false,
11 | debugShrinkResourcesEnabled : false
12 | ]
13 |
14 | }
15 |
16 | repositories {
17 | google()
18 | mavenCentral()
19 |
20 | }
21 | dependencies {
22 | classpath "com.android.tools.build:gradle:${libs.versions.androidGradleVersion.get()}"
23 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlinVersion.get()}"
24 |
25 | }
26 | }
27 |
28 | plugins {
29 | alias(libs.plugins.kotlin.ksp)
30 | alias(libs.plugins.kotlin.android) apply(false)
31 | alias(libs.plugins.kotlin.serialization) apply(false)
32 | alias(libs.plugins.banes.versions)
33 |
34 | }
35 |
36 | allprojects {
37 |
38 | tasks.withType(KotlinCompile).configureEach {
39 | kotlinOptions.jvmTarget = libs.versions.javaVersion.get().toString()
40 | }
41 |
42 | buildDir = "${System.properties['user.home']}${File.separator}.build${File.separator}${rootProject.name}${File.separator}${project.name}"
43 |
44 | }
--------------------------------------------------------------------------------
/commonSettingsAll.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: libs.plugins.kotlin.android.get().pluginId
2 | apply plugin: libs.plugins.kotlin.ksp.get().pluginId
3 | apply plugin: libs.plugins.kotlin.parcelize.get().pluginId
4 |
5 | def libsJavaVersion = libs.versions.javaVersion.get().toString()
6 | def javaVersionEnum = JavaVersion.toVersion(libsJavaVersion)
7 |
8 | android {
9 |
10 | buildFeatures {
11 | buildConfig true
12 | viewBinding true
13 | aidl true
14 | }
15 |
16 |
17 | compileSdk(libs.versions.compileSdkVersion.get().toInteger())
18 | compileOptions {
19 | sourceCompatibility javaVersionEnum
20 | targetCompatibility javaVersionEnum
21 | }
22 |
23 | kotlinOptions {
24 | jvmTarget = javaVersionEnum.toString()
25 |
26 | }
27 |
28 |
29 | defaultConfig {
30 | minSdk(libs.versions.minSdkVersion.get().toInteger())
31 | targetSdk(libs.versions.targetSdkVersion.get().toInteger())
32 | versionName(libs.versions.appVersionName.get())
33 | versionCode(libs.versions.appVersionCode.get().toInteger())
34 | vectorDrawables.useSupportLibrary = true
35 | proguardFiles(
36 | getDefaultProguardFile('proguard-android.txt'),
37 | 'proguard-rules.pro'
38 | )
39 | buildConfigField(
40 | "Integer", "baseVersionCode", "${libs.versions.appVersionCode.get().toInteger()}"
41 | )
42 | }
43 |
44 |
45 | buildTypes {
46 | release {
47 | minifyEnabled(commonValues.releaseMinifyEnabled)
48 | shrinkResources = commonValues.releaseShrinkResourcesEnabled
49 | }
50 | debug {
51 | debuggable true
52 | //use proguard on dev builds too in order to avoid multidex issue
53 | minifyEnabled(commonValues.debugMinifyEnabled)
54 | shrinkResources = commonValues.debugShrinkResourcesEnabled
55 | }
56 | }
57 |
58 | //for google drive rest and onedrive
59 | packaging {
60 |
61 | resources {
62 | /* excludes += [
63 | 'META-INF/DEPENDENCIES',
64 | 'META-INF/LICENSE',
65 | 'META-INF/LICENSE.txt',
66 | 'META-INF/license.txt',
67 | 'META-INF/NOTICE',
68 | 'META-INF/NOTICE.txt',
69 | 'META-INF/notice.txt',
70 | 'META-INF/ASL2.0',
71 | 'META-INF/MANIFEST.MF',
72 | 'META-INF/atomicfu.kotlin_module',
73 | 'META-INF/maven/com.google.guava/guava/pom.properties',
74 | 'META-INF/maven/com.google.guava/guava/pom.xml',
75 | 'META-INF/jersey-module-version'
76 | ]*/
77 |
78 |
79 | }
80 | }
81 |
82 | }
83 |
84 | dependencies {
85 | implementation(libs.bundles.coroutinesBundle)
86 | }
--------------------------------------------------------------------------------
/commonSettingsLibs.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: libs.plugins.android.library.get().pluginId
2 | android{
3 |
4 | defaultConfig {
5 | consumerProguardFiles 'proguard-rules.pro'
6 | }
7 | }
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | ########################################################################################
3 | # App
4 | ########################################################################################
5 | appVersionName = "8"
6 | appVersionCode = "8"
7 |
8 | ########################################################################################
9 | # Build
10 | ########################################################################################
11 | compileSdkVersion = "35"
12 | minSdkVersion = "29"
13 | targetSdkVersion = "35"
14 | javaVersion = "17" # JavaVersion.VERSION_17
15 |
16 | ########################################################################################
17 | # Main
18 | ########################################################################################
19 | androidGradleVersion = "8.10.0-alpha08"
20 | kotlinVersion = "2.1.10"
21 | kspVersion = "2.1.10-1.0.31" # Should only be updated when Kotlin version is updated https://github.com/google/ksp/releases
22 |
23 |
24 | ########################################################################################
25 | # Rest
26 | ########################################################################################
27 | acraVersion = "5.12.0" # https://github.com/ACRA/acra
28 | androidXactivityVersion = "1.10.1" # https://developer.android.com/jetpack/androidx/releases/activity
29 | androidXappCompatVersion = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/appcompat
30 | androidXconstraintLayoutVersion = "2.2.1" # https://developer.android.com/jetpack/androidx/releases/constraintlayout
31 | androidXcoordinatorLayoutVersion = "1.3.0" # https://developer.android.com/jetpack/androidx/releases/coordinatorlayout
32 | androidXfragmentVersion = "1.8.6" # https://developer.android.com/jetpack/androidx/releases/fragment
33 | androidXlifeCycleVersion = "2.8.7" # https://developer.android.com/jetpack/androidx/releases/lifecycle
34 | androidXrecyclerViewVersion = "1.4.0" # https://developer.android.com/jetpack/androidx/releases/recyclerview
35 | androidXstartupVersion = "1.2.0" # https://developer.android.com/jetpack/androidx/releases/startup
36 | androidXCoreVersion = "1.15.0" # https://developer.android.com/jetpack/androidx/releases/core
37 | androidXworkManagerVersion = "2.10.0" # https://developer.android.com/jetpack/androidx/releases/work
38 | androidXannotationVersion = "1.9.1"
39 | banesVersionsVersion = "0.51.0" # https://github.com/ben-manes/gradle-versions-plugin
40 | coilVersion = "2.7.0" # https://github.com/coil-kt/coil
41 | coroutinesVersion = "1.10.1" # https://github.com/Kotlin/kotlinx.coroutines
42 | kotPrefVersion = "2.13.2" # https://github.com/chibatching/Kotpref
43 | materialComponentsVersion = "1.12.0" # https://github.com/material-components/material-components-android/releases
44 | okHttpVersion = "5.0.0-alpha.14" # https://square.github.io/okhttp/
45 | okioVersion ="3.10.2"
46 | kotlinXserializationJsonVersion = "1.8.0" # https://github.com/Kotlin/kotlinx.serialization
47 | retrofitVersion ="2.11.0"
48 | ktorVersion = "3.1.1" # https://ktor.io/
49 |
50 |
51 | [libraries]
52 | acra-core = { module = "ch.acra:acra-core", version.ref = "acraVersion" }
53 | acra-mail = { module = "ch.acra:acra-mail", version.ref = "acraVersion" }
54 | acra-notification = { module = "ch.acra:acra-notification", version.ref = "acraVersion" }
55 | acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acraVersion" }
56 | android-buildtoolsGradle = { module = "com.android.tools.build:gradle", version.ref = "androidGradleVersion" }
57 | androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidXactivityVersion" }
58 | androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidXannotationVersion" }
59 | androidx-appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidXappCompatVersion" }
60 | androidx-appCompat-Resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "androidXappCompatVersion" }
61 | androidx-constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidXconstraintLayoutVersion" }
62 | androidx-coordinatorLayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidXcoordinatorLayoutVersion" }
63 | androidx-coreKtx = { module = "androidx.core:core-ktx", version.ref = "androidXCoreVersion" }
64 | androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidXfragmentVersion" }
65 | androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidXstartupVersion" }
66 | androidx-lifecycle-commonJava8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidXlifeCycleVersion" }
67 | androidx-lifecycle-liveDataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidXlifeCycleVersion" }
68 | androidx-lifecycle-runtimeKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidXlifeCycleVersion" }
69 | androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidXlifeCycleVersion" }
70 | androidx-recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "androidXrecyclerViewVersion" }
71 | androidx-workManagerRuntimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidXworkManagerVersion" }
72 | coil = { module = "io.coil-kt:coil", version.ref = "coilVersion" }
73 | google-materialComponents = { module = "com.google.android.material:material", version.ref = "materialComponentsVersion" }
74 | kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinVersion" }
75 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" }
76 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" }
77 | kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinXserializationJsonVersion" }
78 | kotPref = { module = "com.chibatching.kotpref:kotpref", version.ref = "kotPrefVersion" }
79 | kotPref-enumSupport = { module = "com.chibatching.kotpref:enum-support", version.ref = "kotPrefVersion" }
80 | # Kotpref seems to be firing it 2 times. Tested onBackupActivity
81 | kotPref-liveData = { module = "com.chibatching.kotpref:livedata-support", version.ref = "kotPrefVersion" }
82 | square-okHttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttpVersion" }
83 | square-okHttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okHttpVersion" }
84 | square-okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" }
85 | square-Retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
86 | ktor-Core = { module = "io.ktor:ktor-client-core", version.ref = "ktorVersion" }
87 | ktor-OKHttpEngine = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorVersion" }
88 | ktor-Logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorVersion" }
89 | ktor-Auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktorVersion" }
90 | ktor-Content-Negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorVersion" }
91 | ktor-Serialization-Json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorVersion" }
92 |
93 |
94 | [plugins]
95 | android-application = { id = "com.android.application", version.ref = "androidGradleVersion" }
96 | android-library = { id = "com.android.library", version.ref = "androidGradleVersion" }
97 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinVersion" }
98 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlinVersion" }
99 | kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" }
100 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinVersion" }
101 | banes-versions = { id = "com.github.ben-manes.versions", version.ref = "banesVersionsVersion" } # https://github.com/ben-manes/gradle-versions-plugin
102 |
103 |
104 |
105 | [bundles]
106 | acraBundle = ["acra-core", "acra-mail", "acra-dialog", "acra-notification"]
107 | coroutinesBundle = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
108 | ktorBundle = ["kotlinx-serializationJson", "ktor-Core", "ktor-OKHttpEngine", "ktor-Logging", "ktor-Auth", "ktor-Content-Negotiation", "ktor-Serialization-Json"]
109 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Jan 20 13:30:47 GMT 2023
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/notify/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/notify/build.gradle:
--------------------------------------------------------------------------------
1 | apply from: "$rootProject.projectDir/commonSettingsLibs.gradle"
2 | apply from: "$rootProject.projectDir/commonSettingsAll.gradle"
3 |
4 | android {
5 | namespace "io.karn.notify"
6 | defaultConfig {
7 | consumerProguardFiles 'proguard-rules.pro'
8 | }
9 |
10 | }
11 |
12 | dependencies {
13 | implementation(libs.androidx.coreKtx)
14 | }
15 |
--------------------------------------------------------------------------------
/notify/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in /Users/dialog/Library/Android/sdk/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
19 | # Uncomment this to preserve the line number information for
20 | # debugging stack traces.
21 | #-keepattributes SourceFile,LineNumberTable
22 |
23 | # If you keep the line number information, uncomment this to
24 | # hide the original source file name.
25 | #-renamesourcefileattribute SourceFile
26 |
27 |
28 |
29 | # 09/07/2021
30 | # just updated to
31 | # Android Studio Bumblebee | 2021.1.1 Canary 3
32 | # Build #AI-211.7442.40.2111.7518594, built on July 2, 2021
33 | # And had to add
34 | #
35 | # -keep class io.karn.notify.entities.*
36 | #
37 | # to my proguard files
38 | # Without that line it ket crashing at payload.bubblize at NotificationInterop.kt
39 | -keep class io.karn.notify.entities.*
40 | -keep class io.karn.notify** { *; }
41 | -dontwarn java.lang.invoke.StringConcatFactory
42 |
--------------------------------------------------------------------------------
/notify/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/entities/NotificationChannelGroupInfo.kt:
--------------------------------------------------------------------------------
1 | package io.karn.notify.entities
2 |
3 | data class NotificationChannelGroupInfo(val id: String, val name: String)
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/entities/NotifyConfig.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.entities
26 |
27 | import android.app.NotificationManager
28 |
29 | /**
30 | * Provider of the initial configuration of the Notify > NotifyCreator Fluent API.
31 | */
32 | data class NotifyConfig(
33 | /**
34 | * A reference to the notification manager.
35 | */
36 | internal var notificationManager: NotificationManager? = null,
37 | /**
38 | * Specifies the default configuration of a notification (e.g the default notificationIcon,
39 | * and notification color.)
40 | */
41 | internal var defaultHeader: Payload.Header = Payload.Header(),
42 | /**
43 | * Specifies the default configuration of a progress (e.g the default progress type)
44 | */
45 | internal var defaultProgress: Payload.Progress = Payload.Progress(),
46 | /**
47 | * Specifies the default alerting configuration for notifications.
48 | */
49 | internal var defaultAlerting: Payload.Alerts = Payload.Alerts()
50 | ) {
51 | fun header(init: Payload.Header.() -> Unit): NotifyConfig {
52 | defaultHeader.init()
53 | return this
54 | }
55 |
56 | fun alerting(key: String, init: Payload.Alerts.() -> Unit): NotifyConfig {
57 | // Clone object and assign the key.
58 | defaultAlerting = defaultAlerting.copy(channelKey = key)
59 | defaultAlerting.init()
60 | return this
61 | }
62 |
63 | fun progress(init: Payload.Progress.() -> Unit): NotifyConfig {
64 | defaultProgress.init()
65 | return this
66 | }
67 | }
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/NotificationChannelInterop.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal
26 |
27 | import android.annotation.SuppressLint
28 | import android.app.NotificationChannel
29 | import android.app.NotificationChannelGroup
30 | import android.os.Build
31 | import io.karn.notify.Notify
32 | import io.karn.notify.entities.Payload
33 |
34 | /**
35 | * Provides compatibility functionality for the Notification channels introduced in Android O.
36 | */
37 | internal object NotificationChannelInterop {
38 | @SuppressLint("WrongConstant")
39 | fun with(alerting: Payload.Alerts): Boolean {
40 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
41 | return false
42 | }
43 |
44 | val notificationManager = Notify.defaultConfig.notificationManager!!
45 |
46 | // Ensure that the alerting is not already registered -- return true if it exists.
47 | notificationManager.getNotificationChannel(alerting.channelKey)?.run {
48 | return true
49 | }
50 |
51 | // Create the NotificationChannel, but only on API 26+ because
52 | // the NotificationChannel class is new and not in the support library
53 | /*
54 | https://github.com/Karn/notify/issues/61
55 | Hey @NLLAPPS! This is done because the channel importance is set with accordance to the legacy importance that was used before Notification channels were introduced. The +3 is a quick way to keep the same scheme and be forward-compatible since the value of the channel is exposed as an "enum".
56 | */
57 | val channel = NotificationChannel(alerting.channelKey, alerting.channelName, alerting.channelImportance + 3).apply {
58 | description = alerting.channelDescription
59 |
60 | // Set the lockscreen visibility.
61 | lockscreenVisibility = alerting.lockScreenVisibility
62 |
63 | alerting.lightColor
64 | .takeIf { it != Notify.NO_LIGHTS }
65 | ?.let {
66 | enableLights(true)
67 | lightColor = alerting.lightColor
68 | }
69 |
70 | alerting.vibrationPattern.takeIf { it.isNotEmpty() }?.also {
71 | enableVibration(true)
72 | vibrationPattern = it.toLongArray()
73 | }
74 |
75 | alerting.sound.also {
76 | if (it == null) {
77 | setSound(null, null)
78 |
79 | } else {
80 | setSound(it, alerting.audioAttributes)
81 | }
82 |
83 | }
84 |
85 | setShowBadge(alerting.showBadge)
86 | }
87 |
88 | alerting.notificationChannelGroupInfo?.let {
89 | channel.group = it.id
90 | notificationManager.createNotificationChannelGroup(NotificationChannelGroup(it.id, it.name))
91 | }
92 |
93 | // Register the alerting with the system
94 | notificationManager.createNotificationChannel(channel)
95 |
96 | return true
97 | }
98 | }
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/NotifyExtender.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal
26 |
27 | import android.os.Bundle
28 | import android.service.notification.StatusBarNotification
29 | import androidx.annotation.VisibleForTesting
30 | import androidx.core.app.NotificationCompat
31 |
32 | /**
33 | * Helper class to add Notify Extensions to a notification. The extensions contain data specific to
34 | * notifications created by the Notify class, these extensions include data on functionality such as
35 | * forced stacking.
36 | *
37 | * Notify Extensions can be accessed on an existing notification by using the
38 | * {@code NotifyExtender(Notification)} constructor, and then using property access to get the
39 | * values.
40 | */
41 | internal class NotifyExtender : NotificationCompat.Extender {
42 |
43 | internal companion object {
44 | /**
45 | * Identifies the bundle that is associated
46 | */
47 | private const val EXTRA_NOTIFY_EXTENSIONS = "io.karn.notify.EXTENSIONS"
48 |
49 | // Used to determine if an instance of this class is a valid Notify Notification object.
50 | private const val VALID = "notify_valid"
51 |
52 | // Keys within EXTRA_NOTIFY_EXTENSIONS for synthetic notification options.
53 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
54 | internal const val STACKABLE = "stackable"
55 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
56 | internal const val STACKED = "stacked"
57 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
58 | internal const val STACK_KEY = "stack_key"
59 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
60 | internal const val SUMMARY_CONTENT = "summary_content"
61 |
62 | @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
63 | internal fun getExtensions(extras: Bundle): Bundle {
64 | return extras.getBundle(EXTRA_NOTIFY_EXTENSIONS) ?: Bundle()
65 | }
66 |
67 | internal fun getKey(extras: Bundle): CharSequence? {
68 | return getExtensions(extras).getCharSequence(STACK_KEY, null)
69 | }
70 | }
71 |
72 | var valid: Boolean = false
73 | internal set(value) {
74 | field = value
75 | }
76 |
77 | var stackable: Boolean = false
78 | internal set(value) {
79 | field = value
80 | }
81 | var stacked: Boolean = false
82 | internal set(value) {
83 | field = value
84 | }
85 | var stackKey: CharSequence? = null
86 | internal set(value) {
87 | field = value
88 | }
89 | var stackItems: ArrayList? = null
90 | internal set(value) {
91 | field = value
92 | }
93 |
94 | var summaryContent: CharSequence? = null
95 | internal set(value) {
96 | field = value
97 | }
98 |
99 | constructor() {
100 | this.valid = true
101 | }
102 |
103 | /**
104 | * Build a Notify notification from an existing notification.
105 | */
106 | constructor(notification: StatusBarNotification) {
107 | // Fetch the extensions if any, from a given notification.
108 | NotificationCompat.getExtras(notification.notification)?.let { bundle ->
109 | bundle.getBundle(EXTRA_NOTIFY_EXTENSIONS)?.let {
110 | loadConfigurationFromBundle(it)
111 | }
112 | bundle.getCharSequenceArray(NotificationCompat.EXTRA_TEXT_LINES)?.let {
113 | stackItems = ArrayList(it.toList())
114 | }
115 | }
116 | }
117 |
118 | override fun extend(builder: NotificationCompat.Builder): NotificationCompat.Builder {
119 | val notifyExtensions = builder.extras.getBundle(EXTRA_NOTIFY_EXTENSIONS) ?: Bundle()
120 | loadConfigurationFromBundle(notifyExtensions)
121 |
122 | notifyExtensions.putBoolean(VALID, valid)
123 |
124 | if (stackable) {
125 | notifyExtensions.putBoolean(STACKABLE, stackable)
126 | }
127 |
128 | if (!stackKey.isNullOrBlank()) {
129 | notifyExtensions.putCharSequence(STACK_KEY, stackKey)
130 | }
131 |
132 | if (stacked) {
133 | notifyExtensions.putBoolean(STACKED, stacked)
134 | }
135 |
136 | if (!summaryContent.isNullOrBlank()) {
137 | notifyExtensions.putCharSequence(SUMMARY_CONTENT, summaryContent)
138 | }
139 |
140 | builder.extras.putBundle(EXTRA_NOTIFY_EXTENSIONS, notifyExtensions)
141 | return builder
142 | }
143 |
144 | private fun loadConfigurationFromBundle(bundle: Bundle) {
145 | // Perform an update if exists on all properties.
146 | valid = bundle.getBoolean(VALID, valid)
147 |
148 | stackable = bundle.getBoolean(STACKABLE, stackable)
149 | stacked = bundle.getBoolean(STACKED, stacked)
150 | stackKey = bundle.getCharSequence(STACK_KEY, stackKey)
151 |
152 | summaryContent = bundle.getCharSequence(SUMMARY_CONTENT, summaryContent)
153 | }
154 |
155 | internal fun setStackable(stackable: Boolean = true): NotifyExtender {
156 | this.stackable = stackable
157 | return this
158 | }
159 |
160 | internal fun setStacked(stacked: Boolean = true): NotifyExtender {
161 | this.stacked = stacked
162 | return this
163 | }
164 |
165 | internal fun setKey(key: CharSequence?): NotifyExtender {
166 | this.stackKey = key
167 | return this
168 | }
169 |
170 | internal fun setSummaryText(text: CharSequence?): NotifyExtender {
171 | this.summaryContent = text
172 | return this
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/RawNotification.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal
26 |
27 | import io.karn.notify.entities.Payload
28 | import io.karn.notify.internal.utils.Action
29 |
30 | internal data class RawNotification(
31 | internal val meta: Payload.Meta,
32 | internal val alerting: Payload.Alerts,
33 | internal val header: Payload.Header,
34 | internal val content: Payload.Content,
35 | internal val bubblize: Payload.Bubble?,
36 | internal val stackable: Payload.Stackable?,
37 | internal val actions: ArrayList?,
38 | internal val progress: Payload.Progress
39 | )
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/utils/Aliases.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal.utils
26 |
27 | import androidx.core.app.NotificationCompat
28 |
29 | typealias Action = NotificationCompat.Action
30 |
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/utils/Annotations.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal.utils
26 |
27 | import androidx.annotation.IntDef
28 | import io.karn.notify.Notify
29 |
30 | /**
31 | * Denotes features which are considered experimental and are subject to change without notice.
32 | */
33 | annotation class Experimental
34 |
35 | @DslMarker
36 | annotation class NotifyScopeMarker
37 |
38 | @Retention(AnnotationRetention.SOURCE)
39 | @IntDef(Notify.IMPORTANCE_MIN,
40 | Notify.IMPORTANCE_LOW,
41 | Notify.IMPORTANCE_NORMAL,
42 | Notify.IMPORTANCE_HIGH,
43 | Notify.IMPORTANCE_MAX)
44 | annotation class NotifyImportance
45 |
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/utils/Errors.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal.utils
26 |
27 | internal object Errors {
28 | const val INVALID_STACK_KEY_ERROR = "Invalid stack key provided."
29 | const val INVALID_BUBBLE_ICON_ERROR = "Invalid bubble icon provided."
30 | const val INVALID_BUBBLE_TARGET_ACTIVITY_ERROR = "Invalid target activity provided."
31 | }
32 |
--------------------------------------------------------------------------------
/notify/src/main/java/io/karn/notify/internal/utils/Utils.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * MIT License
3 | *
4 | * Copyright (c) 2018 Karn Saheb
5 | *
6 | * Permission is hereby granted, free of charge, to any person obtaining a copy
7 | * of this software and associated documentation files (the "Software"), to deal
8 | * in the Software without restriction, including without limitation the rights
9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | * copies of the Software, and to permit persons to whom the Software is
11 | * furnished to do so, subject to the following conditions:
12 | *
13 | * The above copyright notice and this permission notice shall be included in all
14 | * copies or substantial portions of the Software.
15 | *
16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | * SOFTWARE.
23 | */
24 |
25 | package io.karn.notify.internal.utils
26 |
27 | import android.text.Html
28 | import java.util.Random
29 |
30 | internal object Utils {
31 | fun getRandomInt(): Int {
32 | return Random().nextInt(Int.MAX_VALUE - 100) + 100
33 | }
34 |
35 | fun getAsSecondaryFormattedText(str: String?): CharSequence? {
36 | str ?: return null
37 |
38 | return Html.fromHtml("$str", Html.FROM_HTML_MODE_LEGACY)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/notify/src/main/res/drawable/ic_android_black.xml:
--------------------------------------------------------------------------------
1 |
24 |
25 |
30 |
33 |
34 |
--------------------------------------------------------------------------------
/notify/src/main/res/drawable/ic_app_icon.xml:
--------------------------------------------------------------------------------
1 |
24 |
25 |
30 |
33 |
36 |
37 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | /**
2 | * How does this work -> https://developer.android.com/studio/preview/features#settings-gradle
3 | */
4 | pluginManagement {
5 | /**
6 | * Repos for root build.gradle/buildscript/dependencies
7 | */
8 | repositories {
9 | gradlePluginPortal()
10 | google()
11 | mavenCentral()
12 |
13 | }
14 | }
15 |
16 | dependencyResolutionManagement {
17 |
18 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
19 | /**
20 | * Repos for all modules. Previously was in build.gradle/allprojects/repositories
21 | */
22 | repositories {
23 | google()
24 | mavenCentral()
25 |
26 | }
27 |
28 |
29 | }
30 |
31 |
32 | rootProject.name = "NLL Store"
33 | include(":app")
34 | include(":notify")
--------------------------------------------------------------------------------