├── .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 | 3 | 4 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /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") --------------------------------------------------------------------------------