diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..01684f26 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/release diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 00000000..476b5d2d --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +Memories \ No newline at end of file diff --git a/android/.idea/codeStyles/Project.xml b/android/.idea/codeStyles/Project.xml new file mode 100644 index 00000000..7643783a --- /dev/null +++ b/android/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..79ee123c --- /dev/null +++ b/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 00000000..b589d56e --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetDropDown.xml b/android/.idea/deploymentTargetDropDown.xml new file mode 100644 index 00000000..c818ab35 --- /dev/null +++ b/android/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 00000000..ae388c2a --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml new file mode 100644 index 00000000..fdf8d994 --- /dev/null +++ b/android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 00000000..a25e2a91 --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/LICENSE b/android/LICENSE new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/android/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..7c1e09b9 --- /dev/null +++ b/android/README.md @@ -0,0 +1,5 @@ +# Memories Android Wrapper + +Android implementation of the NativeX interface. + +Note that all code under this tree is licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html), unlike Memories itself, which is licensed under the AGPLv3 license. diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..250fd012 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'com.google.devtools.ksp' version '1.9.0-1.0.13' +} + +android { + namespace 'gallery.memories' + compileSdk 33 + + defaultConfig { + applicationId "gallery.memories" + minSdk 27 + targetSdk 33 + versionCode 4 + versionName "1.4" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + buildFeatures { + viewBinding true + } +} + +dependencies { + def media_version = "1.1.1" + def room_version = "2.5.2" + + implementation 'androidx.core:core-ktx:1.10.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.6.0' + + implementation 'androidx.exifinterface:exifinterface:1.3.6' + implementation "androidx.media3:media3-exoplayer:$media_version" + implementation "androidx.media3:media3-ui:$media_version" + implementation "androidx.media3:media3-exoplayer-hls:$media_version" + + implementation "androidx.room:room-runtime:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" + + implementation "com.squareup.okhttp3:okhttp:4.10.0" + implementation "io.github.g00fy2:versioncompare:1.5.0" +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7afd2909 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/assets/memories.svg b/android/app/src/main/assets/memories.svg new file mode 100644 index 00000000..70fece33 --- /dev/null +++ b/android/app/src/main/assets/memories.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/styles.css b/android/app/src/main/assets/styles.css new file mode 100644 index 00000000..00082c06 --- /dev/null +++ b/android/app/src/main/assets/styles.css @@ -0,0 +1,107 @@ +:root { + --theme-color: #2b94f0; + --fg-color: white; +} + +body { + margin: 0; + padding: 0; + background-color: var(--theme-color); + color: var(--fg-color); + overflow: hidden; + font-family: sans-serif; +} + +* { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +.container { + width: 90vw; + max-width: 800px; + margin: 40px auto; + text-align: center; +} + +.animatable { + transition: opacity 0.7s ease-in-out, transform 0.7s ease-in-out; +} +.invisible { + transform: translateY(10px); + opacity: 0; +} + +p, +div.p { + color: white; + margin-bottom: 30px; + line-height: 1.5em; +} + +.logo { + color: var(--fg-color); + margin-bottom: 30px; + width: 60vw; + max-width: 400px; +} + +input.m-input { + width: 80vw; + max-width: 800px; + padding: 10px 12px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 10px; + background-color: #f9f9f9; + color: #333; + outline: none; + transition: border-color 0.3s ease-in-out; +} + +input.m-input:focus { + border-color: #0096ff; + box-shadow: 0 0 4px var(--theme-color); +} + +.m-button { + display: inline-block; + padding: 10px 20px; + font-size: 16px; + font-weight: bold; + color: var(--theme-color); + background-color: var(--fg-color); + border: none; + border-radius: 20px; + cursor: pointer; + text-decoration: none; + transition: background-color 0.3s ease-in-out; +} + +.m-button:disabled { + background-color: #eee; + color: #aaa; + cursor: not-allowed; +} + +.m-button.link { + background-color: unset; + color: var(--fg-color); +} + +.login-button { + margin: 10px; + margin-top: 12px; +} + +div.trust { + margin-top: 10px; +} + +input[type="checkbox"] { + width: 1.5em; + height: 1.5em; + vertical-align: -3px; +} \ No newline at end of file diff --git a/android/app/src/main/assets/waiting.html b/android/app/src/main/assets/waiting.html new file mode 100644 index 00000000..24e801ca --- /dev/null +++ b/android/app/src/main/assets/waiting.html @@ -0,0 +1,18 @@ + + + + + Memories + + + + +
+ +

+ Waiting for login to complete
+ Keep this page open in the background +

+
+ + diff --git a/android/app/src/main/assets/welcome.html b/android/app/src/main/assets/welcome.html new file mode 100644 index 00000000..c1ba1a3a --- /dev/null +++ b/android/app/src/main/assets/welcome.html @@ -0,0 +1,136 @@ + + + + + Memories + + + + + + + + + diff --git a/android/app/src/main/java/gallery/memories/MainActivity.kt b/android/app/src/main/java/gallery/memories/MainActivity.kt new file mode 100644 index 00000000..7a1e2a16 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/MainActivity.kt @@ -0,0 +1,379 @@ +package gallery.memories + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.net.http.SslError +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.View +import android.view.WindowInsetsController +import android.webkit.CookieManager +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import gallery.memories.databinding.ActivityMainBinding +import java.util.concurrent.Executors + + +@UnstableApi +class MainActivity : AppCompatActivity() { + companion object { + val TAG = MainActivity::class.java.simpleName + } + + val binding by lazy(LazyThreadSafetyMode.NONE) { + ActivityMainBinding.inflate(layoutInflater) + } + + val threadPool = Executors.newFixedThreadPool(4) + + private lateinit var nativex: NativeX + + private var player: ExoPlayer? = null + private var playerUris: Array? = null + private var playerUid: Long? = null + private var playWhenReady = true + private var mediaItemIndex = 0 + private var playbackPosition = 0L + + private var mNeedRefresh = false + + private val memoriesRegex = Regex("/apps/memories/.*$") + private var host: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + // Restore last known look + restoreTheme() + + // Initialize services + nativex = NativeX(this) + + // Sync if permission is available + nativex.doMediaSync(false) + + // Load JavaScript + initializeWebView() + + // Destroy video after 1 seconds (workaround for video not showing on first load) + binding.videoView.postDelayed({ + binding.videoView.alpha = 1.0f + binding.videoView.visibility = View.GONE + }, 1000) + } + + override fun onDestroy() { + super.onDestroy() + binding.webview.removeAllViews(); + binding.coordinator.removeAllViews() + binding.webview.destroy(); + nativex.destroy() + } + + public override fun onResume() { + super.onResume() + if (playerUris != null && player == null) { + initializePlayer(playerUris!!, playerUid!!) + } + if (mNeedRefresh) { + refreshTimeline(true) + } + } + + public override fun onPause() { + super.onPause() + } + + public override fun onStop() { + super.onStop() + releasePlayer() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> { + if (binding.webview.canGoBack()) { + binding.webview.goBack() + } else { + finish() + } + return true + } + } + } + return super.onKeyDown(keyCode, event) + } + + @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") + private fun initializeWebView() { + // Intercept local APIs + binding.webview.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val pathMatches = request.url.path?.matches(memoriesRegex) == true + val hostMatches = request.url.host.equals(host) + if (pathMatches && hostMatches) { + return false + } + + // Open external links in browser + Intent(Intent.ACTION_VIEW, request.url).apply { startActivity(this) } + + return true + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return if (request.url.host == "127.0.0.1") { + nativex.handleRequest(request) + } else null + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError? + ) { + if (nativex.http.isTrustingAllCertificates) { + handler?.proceed() + } else { + nativex.toast("Failed to load due to SSL error: ${error?.primaryError}", true) + super.onReceivedSslError(view, handler, error) + } + } + } + + // Pass through touch events + binding.webview.setOnTouchListener { _, event -> + if (player != null) { + binding.videoView.dispatchTouchEvent(event) + } + false + } + + val userAgent = + getString(R.string.ua_app_prefix) + BuildConfig.VERSION_NAME + " " + getString(R.string.ua_chrome) + + val webSettings = binding.webview.settings + webSettings.javaScriptEnabled = true + webSettings.javaScriptCanOpenWindowsAutomatically = true + webSettings.allowContentAccess = true + webSettings.domStorageEnabled = true + webSettings.databaseEnabled = true + webSettings.userAgentString = userAgent + webSettings.setSupportZoom(false) + webSettings.builtInZoomControls = false + webSettings.displayZoomControls = false + binding.webview.addJavascriptInterface(nativex, "nativex") + binding.webview.setLayerType(View.LAYER_TYPE_HARDWARE, null) + binding.webview.setBackgroundColor(Color.TRANSPARENT) + // binding.webview.clearCache(true) + // WebView.setWebContentsDebuggingEnabled(true); + + // Welcome page or actual app + nativex.account.refreshCredentials() + val isApp = loadDefaultUrl() + + // Start version check if loaded account + if (isApp) { + // Do not use the threadPool here since this might block indefinitely + Thread { nativex.account.checkCredentialsAndVersion() }.start() + } + } + + fun loadDefaultUrl(): Boolean { + // Load app interface if authenticated + host = nativex.http.loadWebView(binding.webview) + if (host != null) return true + + // Load welcome page + binding.webview.loadUrl("file:///android_asset/welcome.html"); + return false + } + + fun initializePlayer(uris: Array, uid: Long) { + if (player != null) { + if (playerUid == uid) return + player?.release() + player = null + } + + // Prevent re-creating + playerUris = uris + playerUid = uid + + // Build exoplayer + player = ExoPlayer.Builder(this) + .build() + .also { exoPlayer -> + // Bind to player view + binding.videoView.player = exoPlayer + binding.videoView.visibility = View.VISIBLE + binding.videoView.setShowNextButton(false) + binding.videoView.setShowPreviousButton(false) + + for (uri in uris) { + // Create media item from URI + val mediaItem = MediaItem.fromUri(uri) + + // Check if remote or local URI + if (uri.toString().contains("http")) { + // Add cookies from webview to data source + val cookies = CookieManager.getInstance().getCookie(uri.toString()) + val httpDataSourceFactory = + DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(mapOf("cookie" to cookies)) + .setAllowCrossProtocolRedirects(true) + val dataSourceFactory = + DefaultDataSource.Factory(this, httpDataSourceFactory) + + // Check if HLS source from URI (contains .m3u8 anywhere) + exoPlayer.addMediaSource( + if (uri.toString().contains(".m3u8")) { + HlsMediaSource.Factory(dataSourceFactory) + .createMediaSource(mediaItem) + } else { + ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(mediaItem) + } + ) + } else { + exoPlayer.setMediaItems(listOf(mediaItem), mediaItemIndex, playbackPosition) + } + } + + // Catch errors and fall back to other sources + exoPlayer.addListener(object : Player.Listener { + override fun onPlayerError(error: PlaybackException) { + exoPlayer.seekToNext() + exoPlayer.playWhenReady = true + exoPlayer.play() + } + }) + + // Start the player + exoPlayer.playWhenReady = playWhenReady + exoPlayer.prepare() + } + } + + fun destroyPlayer(uid: Long) { + if (playerUid == uid) { + releasePlayer() + + // Reset vars + playWhenReady = true + mediaItemIndex = 0 + playbackPosition = 0L + playerUris = null + playerUid = null + } + } + + private fun releasePlayer() { + player?.let { exoPlayer -> + playbackPosition = exoPlayer.currentPosition + mediaItemIndex = exoPlayer.currentMediaItemIndex + playWhenReady = exoPlayer.playWhenReady + exoPlayer.release() + } + player = null + binding.videoView.visibility = View.GONE + } + + fun storeTheme(color: String?, isDark: Boolean) { + if (color == null) return + getSharedPreferences(getString(R.string.preferences_key), 0).edit() + .putString(getString(R.string.preferences_theme_color), color) + .putBoolean(getString(R.string.preferences_theme_dark), isDark) + .apply() + } + + fun restoreTheme() { + val preferences = getSharedPreferences(getString(R.string.preferences_key), 0) + val color = preferences.getString(getString(R.string.preferences_theme_color), null) + val isDark = preferences.getBoolean(getString(R.string.preferences_theme_dark), false) + applyTheme(color, isDark) + } + + fun applyTheme(color: String?, isDark: Boolean) { + if (color == null) return + + // Set system bars + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val appearance = + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + window.insetsController?.setSystemBarsAppearance( + if (isDark) 0 else appearance, + appearance + ) + } else { + window.decorView.systemUiVisibility = + if (isDark) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } + + // Set colors + try { + val parsed = Color.parseColor(color.trim()) + window.navigationBarColor = parsed + window.statusBarColor = parsed + } catch (e: Exception) { + Log.w(TAG, "Invalid color: $color") + return + } + } + + fun refreshTimeline(force: Boolean = false) { + runOnUiThread { + // Check webview is loaded + if (binding.webview.url == null) return@runOnUiThread + + // Schedule for resume if not active + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) || force) { + mNeedRefresh = false + busEmit("nativex:db:updated") + busEmit("memories:timeline:soft-refresh") + } else { + mNeedRefresh = true + } + } + } + + /** + * Emit an event to the nextcloud event bus + */ + fun busEmit(event: String, data: String = "null") { + runOnUiThread { + if (binding.webview.url == null) return@runOnUiThread + + binding.webview.evaluateJavascript( + "window._nc_event_bus?.emit('$event', $data)", + null + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/NativeX.kt b/android/app/src/main/java/gallery/memories/NativeX.kt new file mode 100644 index 00000000..51366b6f --- /dev/null +++ b/android/app/src/main/java/gallery/memories/NativeX.kt @@ -0,0 +1,308 @@ +package gallery.memories + +import android.net.Uri +import android.util.Log +import android.view.SoundEffectConstants +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.widget.Toast +import androidx.media3.common.util.UnstableApi +import gallery.memories.service.AccountService +import gallery.memories.service.DownloadService +import gallery.memories.service.HttpService +import gallery.memories.service.ImageService +import gallery.memories.service.PermissionsService +import gallery.memories.service.TimelineQuery +import org.json.JSONArray +import java.io.ByteArrayInputStream +import java.net.URLDecoder + +@UnstableApi +class NativeX(private val mCtx: MainActivity) { + val TAG = NativeX::class.java.simpleName + + private var themeStored = false + val query = TimelineQuery(mCtx) + val image = ImageService(mCtx, query) + val http = HttpService() + val account = AccountService(mCtx, http) + val permissions = PermissionsService(mCtx).register() + + init { + dlService = DownloadService(mCtx, query) + } + + companion object { + var dlService: DownloadService? = null + } + + fun destroy() { + dlService = null + query.destroy() + } + + object API { + val LOGIN = Regex("^/api/login/.+$") + + val DAYS = Regex("^/api/days$") + val DAY = Regex("^/api/days/\\d+$") + + val IMAGE_INFO = Regex("^/api/image/info/\\d+$") + val IMAGE_DELETE = Regex("^/api/image/delete/[0-9a-f]+(,[0-9a-f]+)*$") + + val IMAGE_PREVIEW = Regex("^/image/preview/\\d+$") + val IMAGE_FULL = Regex("^/image/full/[0-9a-f]+$") + + val SHARE_URL = Regex("^/api/share/url/.+$") + val SHARE_BLOB = Regex("^/api/share/blob/.+$") + val SHARE_LOCAL = Regex("^/api/share/local/[0-9a-f]+$") + + val CONFIG_ALLOW_MEDIA = Regex("^/api/config/allow_media/\\d+$") + } + + @JavascriptInterface + fun isNative(): Boolean { + return true + } + + @JavascriptInterface + fun setThemeColor(color: String?, isDark: Boolean) { + // Save for getting it back on next start + if (!themeStored && http.isLoggedIn()) { + themeStored = true + mCtx.storeTheme(color, isDark); + } + + // Apply the theme + mCtx.runOnUiThread { + mCtx.applyTheme(color, isDark) + } + } + + @JavascriptInterface + fun playTouchSound() { + mCtx.runOnUiThread { + mCtx.binding.webview.playSoundEffect(SoundEffectConstants.CLICK) + } + } + + @JavascriptInterface + fun toast(message: String, long: Boolean = false) { + mCtx.runOnUiThread { + val duration = if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + Toast.makeText(mCtx, message, duration).show() + } + } + + @JavascriptInterface + fun logout() { + account.loggedOut() + } + + @JavascriptInterface + fun reload() { + mCtx.runOnUiThread { + mCtx.loadDefaultUrl() + } + } + + @JavascriptInterface + fun downloadFromUrl(url: String?, filename: String?) { + if (url == null || filename == null) return; + dlService!!.queue(url, filename) + } + + @JavascriptInterface + fun playVideo(auid: String, fileid: Long, urlsArray: String) { + mCtx.threadPool.submit { + // Get URI of remote videos + val urls = JSONArray(urlsArray) + val list = Array(urls.length()) { + Uri.parse(urls.getString(it)) + } + + // Get URI of local video + val videos = query.getSystemImagesByAUIDs(arrayListOf(auid)) + + // Play with exoplayer + mCtx.runOnUiThread { + if (!videos.isEmpty()) { + mCtx.initializePlayer(arrayOf(videos[0].uri), fileid) + } else { + mCtx.initializePlayer(list, fileid) + } + } + } + } + + @JavascriptInterface + fun destroyVideo(fileid: Long) { + mCtx.runOnUiThread { + mCtx.destroyPlayer(fileid) + } + } + + @JavascriptInterface + fun configSetLocalFolders(json: String?) { + if (json == null) return; + query.localFolders = JSONArray(json) + } + + @JavascriptInterface + fun configGetLocalFolders(): String { + return query.localFolders.toString() + } + + @JavascriptInterface + fun configHasMediaPermission(): Boolean { + return permissions.hasAllowMedia() && permissions.hasMediaPermission() + } + + @JavascriptInterface + fun getSyncStatus(): Int { + return query.syncStatus + } + + @JavascriptInterface + fun setHasRemote(auids: String, buids: String, value: Boolean) { + Log.v(TAG, "setHasRemote: auids=$auids, buids=$buids, value=$value") + mCtx.threadPool.submit { + val auidArray = JSONArray(auids) + val buidArray = JSONArray(buids) + query.setHasRemote( + List(auidArray.length()) { auidArray.getString(it) }, + List(buidArray.length()) { buidArray.getString(it) }, + value + ) + } + } + + fun handleRequest(request: WebResourceRequest): WebResourceResponse { + val path = request.url.path ?: return makeErrorResponse() + + val response = try { + when (request.method) { + "GET" -> { + routerGet(request) + } + + "OPTIONS" -> { + WebResourceResponse( + "text/plain", + "UTF-8", + ByteArrayInputStream("".toByteArray()) + ) + } + + else -> { + throw Exception("Method Not Allowed") + } + } + } catch (e: Exception) { + Log.w(TAG, "handleRequest: " + e.message) + makeErrorResponse() + } + + // Allow CORS from all origins + response.responseHeaders = mutableMapOf( + "Access-Control-Allow-Origin" to "*", + "Access-Control-Allow-Headers" to "*" + ) + + // Cache image responses for 7 days + if (path.matches(API.IMAGE_PREVIEW) || path.matches(API.IMAGE_FULL)) { + response.responseHeaders["Cache-Control"] = "max-age=604800" + } + + return response + } + + @Throws(Exception::class) + private fun routerGet(request: WebResourceRequest): WebResourceResponse { + val path = request.url.path ?: return makeErrorResponse() + + val parts = path.split("/").toTypedArray() + return if (path.matches(API.LOGIN)) { + makeResponse( + account.login( + URLDecoder.decode(parts[3], "UTF-8"), + request.url.getBooleanQueryParameter("trustAll", false) + ) + ) + } else if (path.matches(API.DAYS)) { + makeResponse(query.getDays()) + } else if (path.matches(API.DAY)) { + makeResponse(query.getDay(parts[3].toLong())) + } else if (path.matches(API.IMAGE_INFO)) { + makeResponse(query.getImageInfo(parts[4].toLong())) + } else if (path.matches(API.IMAGE_DELETE)) { + makeResponse( + query.delete( + parseIds(parts[4]), + request.url.getBooleanQueryParameter("dry", false) + ) + ) + } else if (path.matches(API.IMAGE_PREVIEW)) { + makeResponse(image.getPreview(parts[3].toLong()), "image/jpeg") + } else if (path.matches(API.IMAGE_FULL)) { + makeResponse(image.getFull(parts[3]), "image/jpeg") + } else if (path.matches(API.SHARE_URL)) { + makeResponse(dlService!!.shareUrl(URLDecoder.decode(parts[4], "UTF-8"))) + } else if (path.matches(API.SHARE_BLOB)) { + makeResponse(dlService!!.shareBlobFromUrl(URLDecoder.decode(parts[4], "UTF-8"))) + } else if (path.matches(API.SHARE_LOCAL)) { + makeResponse(dlService!!.shareLocal(parts[4])) + } else if (path.matches(API.CONFIG_ALLOW_MEDIA)) { + permissions.setAllowMedia(true) + if (permissions.requestMediaPermissionSync()) { + doMediaSync(true) // separate thread + } + makeResponse("done") + } else { + throw Exception("Path did not match any known API route: $path") + } + } + + private fun makeResponse(bytes: ByteArray?, mimeType: String?): WebResourceResponse { + return if (bytes != null) { + WebResourceResponse(mimeType, "UTF-8", ByteArrayInputStream(bytes)) + } else makeErrorResponse() + } + + private fun makeResponse(json: Any): WebResourceResponse { + return makeResponse(json.toString().toByteArray(), "application/json") + } + + private fun makeErrorResponse(): WebResourceResponse { + val response = WebResourceResponse( + "application/json", + "UTF-8", + ByteArrayInputStream("{}".toByteArray()) + ) + response.setStatusCodeAndReasonPhrase(500, "Internal Server Error") + return response + } + + private fun parseIds(ids: String): List { + return ids.trim().split(",") + } + + fun doMediaSync(forceFull: Boolean) { + if (permissions.hasAllowMedia()) { + // Full sync if this is the first time permission was granted + val fullSync = forceFull || !permissions.hasMediaPermission() + + mCtx.threadPool.submit { + // Block for media permission + if (!permissions.requestMediaPermissionSync()) return@submit + + // Full sync requested + if (fullSync) query.syncFullDb() + + // Run delta sync and register hooks + query.initialize() + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/dao/AppDatabase.kt b/android/app/src/main/java/gallery/memories/dao/AppDatabase.kt new file mode 100644 index 00000000..3cc21c2c --- /dev/null +++ b/android/app/src/main/java/gallery/memories/dao/AppDatabase.kt @@ -0,0 +1,49 @@ +package gallery.memories.dao + +import android.content.Context +import androidx.room.Database +import androidx.room.Room.databaseBuilder +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import gallery.memories.R +import gallery.memories.mapper.Photo + + +@Database(entities = [Photo::class], version = 34) +abstract class AppDatabase : RoomDatabase() { + abstract fun photoDao(): PhotoDao + + companion object { + private val DATABASE_NAME = "memories_room" + @Volatile + private var INSTANCE: AppDatabase? = null + + fun get(context: Context): AppDatabase { + if (INSTANCE == null) { + synchronized(AppDatabase::class.java) { + val ctx = context.applicationContext + if (INSTANCE == null) { + INSTANCE = databaseBuilder(ctx, AppDatabase::class.java, DATABASE_NAME) + .fallbackToDestructiveMigration() + .addCallback(callbacks(ctx)) + .build() + } + } + } + return INSTANCE!! + } + + private fun callbacks(ctx: Context): Callback { + return object : Callback() { + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + super.onDestructiveMigration(db) + + // retrigger synchronization whenever database is destructed + ctx.getSharedPreferences(ctx.getString(R.string.preferences_key), 0).edit() + .remove(ctx.getString(R.string.preferences_last_sync_time)) + .apply() + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt b/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt new file mode 100644 index 00000000..092b617d --- /dev/null +++ b/android/app/src/main/java/gallery/memories/dao/PhotoDao.kt @@ -0,0 +1,47 @@ +package gallery.memories.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import gallery.memories.mapper.Bucket +import gallery.memories.mapper.Day +import gallery.memories.mapper.Photo + +@Dao +interface PhotoDao { + @Query("SELECT 1") + fun ping(): Int + + @Query("SELECT dayid, COUNT(local_id) AS count FROM photos WHERE bucket_id IN (:bucketIds) AND has_remote = 0 GROUP BY dayid ORDER BY dayid DESC") + fun getDays(bucketIds: List): List + + @Query("SELECT * FROM photos WHERE dayid=:dayId AND bucket_id IN (:buckets) AND has_remote = 0 ORDER BY date_taken DESC") + fun getPhotosByDay(dayId: Long, buckets: List): List + + @Query("DELETE FROM photos WHERE local_id IN (:fileIds)") + fun deleteFileIds(fileIds: List) + + @Query("SELECT * FROM photos WHERE local_id IN (:fileIds)") + fun getPhotosByFileIds(fileIds: List): List + + @Query("SELECT * FROM photos WHERE auid IN (:auids)") + fun getPhotosByAUIDs(auids: List): List + + @Query("UPDATE photos SET flag=1") + fun flagAll() + + @Query("UPDATE photos SET flag=0 WHERE local_id=:fileId") + fun unflag(fileId: Long) + + @Query("DELETE FROM photos WHERE flag=1") + fun deleteFlagged() + + @Insert + fun insert(vararg photos: Photo) + + @Query("SELECT bucket_id, bucket_name FROM photos GROUP BY bucket_id") + fun getBuckets(): List + + @Query("UPDATE photos SET has_remote=:v WHERE auid IN (:auids) OR buid IN (:buids)") + fun setHasRemote(auids: List, buids: List, v: Boolean) +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/Bucket.kt b/android/app/src/main/java/gallery/memories/mapper/Bucket.kt new file mode 100644 index 00000000..6ace36fa --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/Bucket.kt @@ -0,0 +1,8 @@ +package gallery.memories.mapper + +import androidx.room.ColumnInfo + +data class Bucket( + @ColumnInfo(name = "bucket_id") val id: String, + @ColumnInfo(name = "bucket_name") val name: String, +) \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/Day.kt b/android/app/src/main/java/gallery/memories/mapper/Day.kt new file mode 100644 index 00000000..e7926ba4 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/Day.kt @@ -0,0 +1,8 @@ +package gallery.memories.mapper + +import androidx.room.ColumnInfo + +data class Day( + @ColumnInfo(name = "dayid") val dayId: Long, + @ColumnInfo(name = "count") val count: Long +) \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/Fields.kt b/android/app/src/main/java/gallery/memories/mapper/Fields.kt new file mode 100644 index 00000000..48cf6ff4 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/Fields.kt @@ -0,0 +1,59 @@ +package gallery.memories.mapper + +import androidx.exifinterface.media.ExifInterface + +class Fields { + object Day { + const val DAYID = Photo.DAYID + const val COUNT = "count" + } + + object Photo { + const val FILEID = "fileid" + const val BASENAME = "basename" + const val MIMETYPE = "mimetype" + const val HEIGHT = "h" + const val WIDTH = "w" + const val SIZE = "size" + const val ETAG = "etag" + const val DATETAKEN = "datetaken" + const val EPOCH = "epoch" + const val AUID = "auid" + const val BUID = "buid" + const val DAYID = "dayid" + const val ISVIDEO = "isvideo" + const val VIDEO_DURATION = "video_duration" + const val EXIF = "exif" + const val PERMISSIONS = "permissions" + } + + object Perm { + const val DELETE = "D" + } + + object EXIF { + val MAP = mapOf( + ExifInterface.TAG_APERTURE_VALUE to "Aperture", + ExifInterface.TAG_FOCAL_LENGTH to "FocalLength", + ExifInterface.TAG_F_NUMBER to "FNumber", + ExifInterface.TAG_SHUTTER_SPEED_VALUE to "ShutterSpeed", + ExifInterface.TAG_EXPOSURE_TIME to "ExposureTime", + ExifInterface.TAG_ISO_SPEED to "ISO", + ExifInterface.TAG_DATETIME_ORIGINAL to "DateTimeOriginal", + ExifInterface.TAG_OFFSET_TIME_ORIGINAL to "OffsetTimeOriginal", + ExifInterface.TAG_GPS_LATITUDE to "GPSLatitude", + ExifInterface.TAG_GPS_LONGITUDE to "GPSLongitude", + ExifInterface.TAG_GPS_ALTITUDE to "GPSAltitude", + ExifInterface.TAG_MAKE to "Make", + ExifInterface.TAG_MODEL to "Model", + ExifInterface.TAG_ORIENTATION to "Orientation", + ExifInterface.TAG_IMAGE_DESCRIPTION to "Description" + ) + } + + object Bucket { + const val ID = "id" + const val NAME = "name" + const val ENABLED = "enabled" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/Photo.kt b/android/app/src/main/java/gallery/memories/mapper/Photo.kt new file mode 100644 index 00000000..f49a9ac8 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/Photo.kt @@ -0,0 +1,32 @@ +package gallery.memories.mapper + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "photos", indices = [ + Index(value = ["local_id"]), + Index(value = ["auid"]), + Index(value = ["buid"]), + Index(value = ["dayid"]), + Index(value = ["flag"]), + Index(value = ["bucket_id"]), + Index(value = ["bucket_id", "dayid", "has_remote"]) + ] +) +data class Photo( + @PrimaryKey(autoGenerate = true) val id: Int? = null, + @ColumnInfo(name = "local_id") val localId: Long, + @ColumnInfo(name = "auid") val auid: String, + @ColumnInfo(name = "buid") val buid: String, + @ColumnInfo(name = "mtime") val mtime: Long, + @ColumnInfo(name = "date_taken") val dateTaken: Long, + @ColumnInfo(name = "dayid") val dayId: Long, + @ColumnInfo(name = "basename") val baseName: String, + @ColumnInfo(name = "bucket_id") val bucketId: Long, + @ColumnInfo(name = "bucket_name") val bucketName: String, + @ColumnInfo(name = "has_remote") val hasRemote: Boolean, + @ColumnInfo(name = "flag") val flag: Int +) \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/Response.kt b/android/app/src/main/java/gallery/memories/mapper/Response.kt new file mode 100644 index 00000000..f189495d --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/Response.kt @@ -0,0 +1,12 @@ +package gallery.memories.mapper + +import org.json.JSONObject + +class Response { + companion object { + val OK + get(): JSONObject { + return JSONObject().put("message", "ok") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt new file mode 100644 index 00000000..d6840238 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/mapper/SystemImage.kt @@ -0,0 +1,262 @@ +package gallery.memories.mapper + +import android.content.ContentUris +import android.content.Context +import android.icu.text.SimpleDateFormat +import android.icu.util.TimeZone +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import androidx.exifinterface.media.ExifInterface +import org.json.JSONObject +import java.io.IOException +import java.math.BigInteger +import java.security.MessageDigest + +class SystemImage { + var fileId = 0L + var baseName = "" + var mimeType = "" + var dateTaken = 0L + var height = 0L + var width = 0L + var size = 0L + var mtime = 0L + var dataPath = "" + var bucketId = 0L + var bucketName = "" + + var isVideo = false + var videoDuration = 0L + + val uri: Uri + get() { + return ContentUris.withAppendedId(mCollection, fileId) + } + + private var mCollection: Uri = IMAGE_URI + + companion object { + val TAG = SystemImage::class.java.simpleName + val IMAGE_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val VIDEO_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + + /** + * Iterate over all images/videos in the given collection + * @param ctx Context - application context + * @param collection Uri - either IMAGE_URI or VIDEO_URI + * @param selection String? - selection string + * @param selectionArgs Array? - selection arguments + * @param sortOrder String? - sort order + * @return Sequence + */ + fun cursor( + ctx: Context, + collection: Uri, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ) = sequence { + // Base fields common for videos and images + val projection = arrayListOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.MIME_TYPE, + MediaStore.Images.Media.HEIGHT, + MediaStore.Images.Media.WIDTH, + MediaStore.Images.Media.SIZE, + MediaStore.Images.Media.ORIENTATION, + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.DATE_MODIFIED, + MediaStore.Images.Media.DATA, + MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, + ) + + // Add video-specific fields + if (collection == VIDEO_URI) { + projection.add(MediaStore.Video.Media.DURATION) + } + + // Get column indices + val idColumn = projection.indexOf(MediaStore.Images.Media._ID) + val nameColumn = projection.indexOf(MediaStore.Images.Media.DISPLAY_NAME) + val mimeColumn = projection.indexOf(MediaStore.Images.Media.MIME_TYPE) + val heightColumn = projection.indexOf(MediaStore.Images.Media.HEIGHT) + val widthColumn = projection.indexOf(MediaStore.Images.Media.WIDTH) + val sizeColumn = projection.indexOf(MediaStore.Images.Media.SIZE) + val orientationColumn = projection.indexOf(MediaStore.Images.Media.ORIENTATION) + val dateTakenColumn = projection.indexOf(MediaStore.Images.Media.DATE_TAKEN) + val dateModifiedColumn = projection.indexOf(MediaStore.Images.Media.DATE_MODIFIED) + val dataColumn = projection.indexOf(MediaStore.Images.Media.DATA) + val bucketIdColumn = projection.indexOf(MediaStore.Images.Media.BUCKET_ID) + val bucketNameColumn = projection.indexOf(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + + // Query content resolver + ctx.contentResolver.query( + collection, + projection.toTypedArray(), + selection, + selectionArgs, + sortOrder + ).use { cursor -> + while (cursor!!.moveToNext()) { + val image = SystemImage() + + // Common fields + image.fileId = cursor.getLong(idColumn) + image.baseName = cursor.getString(nameColumn) + image.mimeType = cursor.getString(mimeColumn) + image.height = cursor.getLong(heightColumn) + image.width = cursor.getLong(widthColumn) + image.size = cursor.getLong(sizeColumn) + image.dateTaken = cursor.getLong(dateTakenColumn) + image.mtime = cursor.getLong(dateModifiedColumn) + image.dataPath = cursor.getString(dataColumn) + image.bucketId = cursor.getLong(bucketIdColumn) + image.bucketName = cursor.getString(bucketNameColumn) + image.mCollection = collection + + // Swap width/height if orientation is 90 or 270 + val orientation = cursor.getInt(orientationColumn) + if (orientation == 90 || orientation == 270) { + image.width = image.height.also { image.height = image.width } + } + + // Video specific fields + image.isVideo = collection == VIDEO_URI + if (image.isVideo) { + val durationColumn = projection.indexOf(MediaStore.Video.Media.DURATION) + image.videoDuration = cursor.getLong(durationColumn) + } + + // Add to main list + yield(image) + } + } + } + + /** + * Get image or video by a list of IDs + * @param ctx Context - application context + * @param ids List - list of IDs + * @return List + */ + fun getByIds(ctx: Context, ids: List): List { + val selection = MediaStore.Images.Media._ID + " IN (" + ids.joinToString(",") + ")" + val images = cursor(ctx, IMAGE_URI, selection, null, null).toList() + if (images.size == ids.size) return images + return images + cursor(ctx, VIDEO_URI, selection, null, null).toList() + } + } + + /** + * JSON representation of the SystemImage. + * This corresponds to IPhoto on the frontend. + */ + val json + get(): JSONObject { + val obj = JSONObject() + .put(Fields.Photo.FILEID, fileId) + .put(Fields.Photo.BASENAME, baseName) + .put(Fields.Photo.MIMETYPE, mimeType) + .put(Fields.Photo.HEIGHT, height) + .put(Fields.Photo.WIDTH, width) + .put(Fields.Photo.SIZE, size) + .put(Fields.Photo.ETAG, mtime.toString()) + .put(Fields.Photo.EPOCH, epoch) + + if (isVideo) { + obj.put(Fields.Photo.ISVIDEO, 1) + .put(Fields.Photo.VIDEO_DURATION, videoDuration / 1000) + } + + return obj + } + + /** The epoch timestamp of the image. */ + val epoch + get(): Long { + return dateTaken / 1000 + } + + val exifInterface + get() : ExifInterface? { + if (isVideo) return null + try { + return ExifInterface(dataPath) + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF data: " + e.message) + return null + } + } + + /** The UTC dateTaken timestamp of the image. */ + fun utcDate(exif: ExifInterface?): Long { + // Get EXIF date using ExifInterface if image + if (exif != null) { + try { + val exifDate = exif.getAttribute(ExifInterface.TAG_DATETIME) + ?: throw IOException() + val sdf = SimpleDateFormat("yyyy:MM:dd HH:mm:ss") + sdf.timeZone = TimeZone.GMT_ZONE + sdf.parse(exifDate).let { + return it.time / 1000 + } + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF datetime: " + e.message) + } + } + + // No way to get the actual local date, so just assume current timezone + return (dateTaken + TimeZone.getDefault().getOffset(dateTaken).toLong()) / 1000 + } + + fun auid(): String { + return md5("$epoch$size") + } + + fun buid(exif: ExifInterface?): String { + var sfx = "size=$size" + if (exif != null) { + try { + val iuid = exif.getAttribute(ExifInterface.TAG_IMAGE_UNIQUE_ID) + ?: throw IOException() + sfx = "iuid=$iuid" + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF unique ID ($baseName): " + e.message) + } + } + + return md5("$baseName$sfx"); + } + + /** + * The database Photo object corresponding to the SystemImage. + * This should ONLY be used for insertion into the database. + */ + val photo + get(): Photo { + val exif = exifInterface + val dateCache = utcDate(exif) + + return Photo( + localId = fileId, + auid = auid(), + buid = buid(exif), + mtime = mtime, + dateTaken = dateCache, + dayId = dateCache / 86400, + baseName = baseName, + bucketId = bucketId, + bucketName = bucketName, + flag = 0, + hasRemote = false + ) + } + + private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/AccountService.kt b/android/app/src/main/java/gallery/memories/service/AccountService.kt new file mode 100644 index 00000000..99594409 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/AccountService.kt @@ -0,0 +1,224 @@ +package gallery.memories.service + +import SecureStorage +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.media3.common.util.UnstableApi +import gallery.memories.MainActivity +import gallery.memories.R +import io.github.g00fy2.versioncompare.Version + +@UnstableApi +class AccountService(private val mCtx: MainActivity, private val mHttp: HttpService) { + companion object { + val TAG = AccountService::class.java.simpleName + } + + private val store = SecureStorage(mCtx) + + /** + * Make the first request to log in + * @param url The URL of the Nextcloud server + * @param trustAll Whether to trust all certificates + */ + fun login(url: String, trustAll: Boolean) { + try { + mHttp.build(url, trustAll) + + val res = mHttp.getApiDescription() + if (res.code != 200) { + throw Exception("${url}api/describe (status ${res.code})") + } + + val body = mHttp.bodyJson(res) ?: throw Exception("Failed to parse API description") + + val baseUrl = body.getString("baseUrl") + val loginFlowUrl = body.getString("loginFlowUrl") + loginFlow(baseUrl, loginFlowUrl) + } catch (e: Exception) { + toast("Error: ${e.message}") + throw Exception("Failed to connect to server: ${e.message}") + } + } + + /** + * Login to a server + * @param baseUrl The base URL of the server + * @param loginFlowUrl The login flow URL + * @throws Exception If the login flow failed + */ + fun loginFlow(baseUrl: String, loginFlowUrl: String) { + val res = mHttp.postLoginFlow(loginFlowUrl) + + // Check if 200 was received + if (res.code != 200) { + throw Exception("Login flow returned a ${res.code} status code. Check your reverse proxy configuration and overwriteprotocol is correct.") + } + + // Get body as JSON + val body = mHttp.bodyJson(res) ?: throw Exception("Failed to parse login flow response") + + // Parse response body as JSON + val pollObj = body.getJSONObject("poll") + val pollToken = pollObj.getString("token") + val pollUrl = pollObj.getString("endpoint") + val loginUrl = body.getString("login") + + // Open login page in browser + mCtx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl))) + + // Start polling in background + Thread { pollLogin(pollUrl, pollToken, baseUrl) }.start() + } + + /** + * Poll the login flow URL until we get a login token + * @param pollUrl The login flow URL + * @param pollToken The login token + * @param baseUrl The base URL of the server + */ + private fun pollLogin(pollUrl: String, pollToken: String, baseUrl: String) { + mCtx.binding.webview.post { + mCtx.binding.webview.loadUrl("file:///android_asset/waiting.html") + } + + var pollCount = 0 + while (pollCount < 10 * 60) { + pollCount += 3 + + // Sleep for 3s + Thread.sleep(3000) + + try { + val response = mHttp.getPollLogin(pollUrl, pollToken) + val body = mHttp.bodyJson(response) ?: throw Exception("Failed to parse login flow response") + Log.v(TAG, "pollLogin: Got status code ${response.code}") + + // Check status code + if (response.code != 200) { + throw Exception("Failed to poll login flow") + } + + val loginName = body.getString("loginName") + val appPassword = body.getString("appPassword") + + toast("Logged in, waiting for next page ...") + + mCtx.runOnUiThread { + // Save login info (also updates header) + storeCredentials(baseUrl, loginName, appPassword) + + // Go to next screen + mHttp.loadWebView(mCtx.binding.webview, "nxsetup") + } + + return + } catch (e: Exception) { + continue + } + } + } + + /** + * Check if the credentials are valid and the server version is supported + * Makes a toast to the user if something is wrong + */ + fun checkCredentialsAndVersion() { + if (!mHttp.isLoggedIn()) return + + try { + val response = mHttp.getApiDescription() + val body = mHttp.bodyJson(response) + + // Check status code + if (response.code == 401) { + return loggedOut() + } + + // Could not connect to memories + if (response.code == 404) { + return toast(mCtx.getString(R.string.err_no_ver)) + } + + // Check body + if (body == null || response.code != 200) { + toast(mCtx.getString(R.string.err_no_describe)) + return + } + + // Get body values + val uid = body.get("uid") + val version = body.getString("version") + + // Check UID exists + if (uid.equals(null)) { + return loggedOut() + } + + // Check minimum version + if (Version(version) < Version(mCtx.getString(R.string.min_server_version))) { + return toast(mCtx.getString(R.string.err_no_ver)) + } + } catch (e: Exception) { + Log.w(TAG, "checkCredentialsAndVersion: ", e) + return + } + } + + /** + * Handle a logout. Delete the stored credentials and go back to the login screen. + */ + fun loggedOut() { + toast(mCtx.getString(R.string.err_logged_out)) + deleteCredentials() + mCtx.runOnUiThread { + mCtx.loadDefaultUrl() + } + } + + /** + * Store the credentials + * @param url The URL to store + * @param user The username to store + * @param password The password to store + */ + fun storeCredentials(url: String, user: String, password: String) { + store.saveCredentials(Credential( + url = url, + trustAll = mHttp.isTrustingAllCertificates, + username = user, + token = password, + )) + refreshCredentials() + } + + /** + * Delete the stored credentials + */ + fun deleteCredentials() { + store.deleteCredentials() + mHttp.setAuthHeader(null) + mHttp.build(null, false) + } + + /** + * Refresh the authorization header + */ + fun refreshCredentials() { + val cred = store.getCredentials() ?: return + mHttp.build(cred.url, cred.trustAll) + mHttp.setAuthHeader(Pair(cred.username, cred.token)) + } + + /** + * Show a toast on the UI thread + * @param message The message to show + */ + private fun toast(message: String) { + mCtx.runOnUiThread { + Toast.makeText(mCtx, message, Toast.LENGTH_LONG).show() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/ConfigService.kt b/android/app/src/main/java/gallery/memories/service/ConfigService.kt new file mode 100644 index 00000000..2fd97de6 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/ConfigService.kt @@ -0,0 +1,33 @@ +package gallery.memories.service + +import android.content.Context +import gallery.memories.R + +class ConfigService(private val mCtx: Context) { + companion object { + private var mEnabledBuckets: List? = null + } + + /** + * Get the list of enabled local folders + * @return The list of enabled local folders + */ + var enabledBucketIds: List + get() { + if (mEnabledBuckets != null) return mEnabledBuckets!! + mEnabledBuckets = mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0) + .getStringSet(mCtx.getString(R.string.preferences_enabled_local_folders), null) + ?.toList() + ?: listOf() + return mEnabledBuckets!! + } + set(value) { + mEnabledBuckets = value + mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0).edit() + .putStringSet( + mCtx.getString(R.string.preferences_enabled_local_folders), + value.toSet() + ) + .apply() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/Credential.kt b/android/app/src/main/java/gallery/memories/service/Credential.kt new file mode 100644 index 00000000..2c4be08e --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/Credential.kt @@ -0,0 +1,8 @@ +package gallery.memories.service + +data class Credential( + var url: String, + var trustAll: Boolean, + var username: String, + var token: String, +) diff --git a/android/app/src/main/java/gallery/memories/service/DownloadBroadcastReceiver.kt b/android/app/src/main/java/gallery/memories/service/DownloadBroadcastReceiver.kt new file mode 100644 index 00000000..026ed3b7 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/DownloadBroadcastReceiver.kt @@ -0,0 +1,16 @@ +package gallery.memories.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.media3.common.util.UnstableApi +import gallery.memories.NativeX + +@UnstableApi class DownloadBroadcastReceiver : BroadcastReceiver() { + /** + * Callback when download is complete + */ + override fun onReceive(context: Context, intent: Intent) { + NativeX.dlService?.runDownloadCallback(intent) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/DownloadService.kt b/android/app/src/main/java/gallery/memories/service/DownloadService.kt new file mode 100644 index 00000000..8807195d --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/DownloadService.kt @@ -0,0 +1,142 @@ +package gallery.memories.service + +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.webkit.CookieManager +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.collection.ArrayMap +import androidx.media3.common.util.UnstableApi +import java.util.concurrent.CountDownLatch + +@UnstableApi class DownloadService(private val mActivity: AppCompatActivity, private val query: TimelineQuery) { + private val mDownloads: MutableMap Unit> = ArrayMap() + + /** + * Callback when download is complete + * @param intent The intent that triggered the callback + */ + fun runDownloadCallback(intent: Intent) { + if (mActivity.isDestroyed) return + + if (DownloadManager.ACTION_DOWNLOAD_COMPLETE == intent.action) { + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) + synchronized(mDownloads) { + mDownloads[id]?.let { + it() + mDownloads.remove(id) + return + } + } + + Toast.makeText(mActivity, "Download Complete", Toast.LENGTH_SHORT).show() + } + } + + /** + * Queue a download + * @param url The URL to download + * @param filename The filename to save the download as + * @return The download ID + */ + fun queue(url: String, filename: String): Long { + val uri = Uri.parse(url) + val manager = mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(uri) + request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + + // Copy all cookies from the webview to the download request + val cookies = CookieManager.getInstance().getCookie(url) + request.addRequestHeader("cookie", cookies) + if (filename != "") { + // Save the file to external storage + request.setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + "memories/$filename" + ) + } + + // Start the download + return manager.enqueue(request) + } + + /** + * Share a URL as a string + * @param url The URL to share + * @return True if the URL was shared + */ + fun shareUrl(url: String): Boolean { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + intent.putExtra(Intent.EXTRA_TEXT, url) + mActivity.startActivity(Intent.createChooser(intent, null)) + return true + } + + /** + * Share a URL as a blob + * @param url The URL to share + * @return True if the URL was shared + */ + @Throws(Exception::class) + fun shareBlobFromUrl(url: String): Boolean { + val id = queue(url, "") + val latch = CountDownLatch(1) + synchronized(mDownloads) { + mDownloads.put(id, fun() { latch.countDown() }) + } + latch.await() + + // Get the URI of the downloaded file + val sUri = getDownloadedFileURI(id) ?: throw Exception("Failed to download file") + val uri = Uri.parse(sUri) + + // Create sharing intent + val intent = Intent(Intent.ACTION_SEND) + intent.type = mActivity.contentResolver.getType(uri) + intent.putExtra(Intent.EXTRA_STREAM, uri) + mActivity.startActivity(Intent.createChooser(intent, null)) + return true + } + + /** + * Share a local image + * @param auid The AUID of the image to share + * @return True if the image was shared + */ + @Throws(Exception::class) + fun shareLocal(auid: String): Boolean { + val sysImgs = query.getSystemImagesByAUIDs(listOf(auid)) + if (sysImgs.isEmpty()) throw Exception("Image not found locally") + val uri = sysImgs[0].uri + + val intent = Intent(Intent.ACTION_SEND) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.type = mActivity.contentResolver.getType(uri) + intent.putExtra(Intent.EXTRA_STREAM, uri) + mActivity.startActivity(Intent.createChooser(intent, null)) + return true + } + + /** + * Get the URI of a downloaded file from download ID + * @param downloadId The download ID + * @return The URI of the downloaded file + */ + private fun getDownloadedFileURI(downloadId: Long): String? { + val downloadManager = + mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val query = DownloadManager.Query() + query.setFilterById(downloadId) + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val columnIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) + return cursor.getString(columnIndex) + } + cursor.close() + return null + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/HttpService.kt b/android/app/src/main/java/gallery/memories/service/HttpService.kt new file mode 100644 index 00000000..a8c806e5 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/HttpService.kt @@ -0,0 +1,194 @@ +package gallery.memories.service + +import android.net.Uri +import android.util.Base64 +import android.webkit.WebView +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + + +class HttpService { + companion object { + val TAG = HttpService::class.java.simpleName + } + + private var client = OkHttpClient() + private var authHeader: String? = null + private var mBaseUrl: String? = null + private var mTrustAll = false + + /** + * Check if all certificates are trusted + */ + val isTrustingAllCertificates: Boolean + get() = mTrustAll + + /** + * Check if the HTTP service is logged in + */ + fun isLoggedIn(): Boolean { + return authHeader != null + } + + /** + * Build the HTTP client + * @param url The URL to use + * @param trustAll Whether to trust all certificates + */ + fun build(url: String?, trustAll: Boolean) { + mBaseUrl = url + mTrustAll = trustAll + client = if (trustAll) { + val trustAllCerts = arrayOf( + object : X509TrustManager { + @Throws(CertificateException::class) + override fun checkClientTrusted( + chain: Array, + authType: String + ) { + } + + @Throws(CertificateException::class) + override fun checkServerTrusted( + chain: Array, + authType: String + ) { + } + + override fun getAcceptedIssuers(): Array { + return arrayOf() + } + } + ) + + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, SecureRandom()) + + OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier({ hostname, session -> true }) + .build() + } else { + OkHttpClient() + } + } + + /** + * Set the authorization header + * @param credentials The credentials to use + */ + fun setAuthHeader(credentials: Pair?) { + if (credentials != null) { + val auth = "${credentials.first}:${credentials.second}" + authHeader = "Basic ${Base64.encodeToString(auth.toByteArray(), Base64.NO_WRAP)}" + return + } + authHeader = null + } + + /** + * Load a webview at the default page + * @param webView The webview to load + * @param subpath The subpath to load + * @return Host URL if authenticated, null otherwise + */ + fun loadWebView(webView: WebView, subpath: String? = null): String? { + // Load app interface if authenticated + if (authHeader != null && mBaseUrl != null) { + var url = mBaseUrl + if (subpath != null) url += subpath + + // Get host name + val host = Uri.parse(url).host + + // Clear webview history + webView.clearHistory() + + // Set authorization header + webView.loadUrl(url!!, mapOf("Authorization" to authHeader)) + + return host + } + + return null + } + + /** Get body as JSON Object */ + @Throws(Exception::class) + fun bodyJson(response: Response): JSONObject? { + return getBody(response)?.let { JSONObject(it) } + } + + /** Get body as JSON array */ + @Throws(Exception::class) + fun bodyJsonArray(response: Response): JSONArray? { + return getBody(response)?.let { JSONArray(it) } + } + + /** Get a string from the response body */ + @Throws(Exception::class) + fun getBody(response: Response): String? { + val body = response.body?.string() + response.body?.close() + return body + } + + /** Get the API description request */ + @Throws(Exception::class) + fun getApiDescription(): Response { + return runRequest(buildGet("api/describe")) + } + + /** Make login flow request */ + @Throws(Exception::class) + fun postLoginFlow(loginFlowUrl: String): Response { + return runRequest( + Request.Builder() + .url(loginFlowUrl) + .header("User-Agent", "Memories") + .post("".toRequestBody("application/json".toMediaTypeOrNull())) + .build() + ) + } + + /** Make login polling request */ + @Throws(Exception::class) + fun getPollLogin(pollUrl: String, pollToken: String): Response { + return runRequest( + Request.Builder() + .url(pollUrl) + .post("token=$pollToken".toRequestBody("application/x-www-form-urlencoded".toMediaTypeOrNull())) + .build() + ) + } + + /** Run a request and get a JSON object */ + @Throws(Exception::class) + private fun runRequest(request: Request): Response { + return client.newCall(request).execute() + } + + /** Build a GET request */ + private fun buildGet(path: String, auth: Boolean = true): Request { + val builder = Request.Builder() + .url(mBaseUrl + path) + .header("User-Agent", "Memories") + .get() + + if (auth) + builder.header("Authorization", authHeader ?: "") + + return builder.build() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/ImageService.kt b/android/app/src/main/java/gallery/memories/service/ImageService.kt new file mode 100644 index 00000000..e26f075f --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/ImageService.kt @@ -0,0 +1,70 @@ +package gallery.memories.service + +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build +import android.provider.MediaStore +import androidx.media3.common.util.UnstableApi +import java.io.ByteArrayOutputStream + +@UnstableApi class ImageService(private val mCtx: Context, private val query: TimelineQuery) { + /** + * Get a preview image for a given image ID + * @param id The image ID + * @return The preview image as a JPEG byte array + */ + @Throws(Exception::class) + fun getPreview(id: Long): ByteArray { + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + mCtx.contentResolver.loadThumbnail( + ContentUris.withAppendedId( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + id + ), + android.util.Size(2048, 2048), + null + ) + } else { + MediaStore.Images.Thumbnails.getThumbnail( + mCtx.contentResolver, id, MediaStore.Images.Thumbnails.FULL_SCREEN_KIND, null + ) + ?: MediaStore.Video.Thumbnails.getThumbnail( + mCtx.contentResolver, id, MediaStore.Video.Thumbnails.FULL_SCREEN_KIND, null + ) + ?: throw Exception("Thumbnail not found") + } + + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) + return stream.toByteArray() + } + + /** + * Get a full image for a given image ID + * @param id The image ID + * @return The full image as a JPEG byte array + */ + @Throws(Exception::class) + fun getFull(auid: String): ByteArray { + val sysImgs = query.getSystemImagesByAUIDs(listOf(auid)) + if (sysImgs.isEmpty()) { + throw Exception("Image not found") + } + + val uri = sysImgs[0].uri + + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(mCtx.contentResolver, uri)) + } else { + MediaStore.Images.Media.getBitmap(mCtx.contentResolver, uri) + ?: throw Exception("Image not found") + } + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) + return stream.toByteArray() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/PermissionsService.kt b/android/app/src/main/java/gallery/memories/service/PermissionsService.kt new file mode 100644 index 00000000..94908a6b --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/PermissionsService.kt @@ -0,0 +1,80 @@ +package gallery.memories.service + +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.media3.common.util.UnstableApi +import gallery.memories.MainActivity +import gallery.memories.R +import java.util.concurrent.CountDownLatch + +@UnstableApi class PermissionsService(private val activity: MainActivity) { + var isGranted: Boolean = false + var latch: CountDownLatch? = null + lateinit var requestPermissionLauncher: ActivityResultLauncher> + + fun register(): PermissionsService { + requestPermissionLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + // we need all of these + isGranted = permissions.all { it.value } + + // Persist that we have it now + setHasMediaPermission(isGranted) + + // Release latch + latch?.countDown() + } + + return this + } + + /** + * Requests media permission and blocks until it is granted + */ + fun requestMediaPermissionSync(): Boolean { + if (isGranted) return true + + // Wait for response + latch = CountDownLatch(1) + + // Request media read permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch( + arrayOf( + android.Manifest.permission.READ_MEDIA_IMAGES, + android.Manifest.permission.READ_MEDIA_VIDEO, + ) + ) + } else { + requestPermissionLauncher.launch(arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)) + } + + latch?.await() + + return isGranted + } + + fun hasMediaPermission(): Boolean { + return activity.getSharedPreferences(activity.getString(R.string.preferences_key), 0) + .getBoolean(activity.getString(R.string.preferences_has_media_permission), false) + } + + private fun setHasMediaPermission(v: Boolean) { + activity.getSharedPreferences(activity.getString(R.string.preferences_key), 0).edit() + .putBoolean(activity.getString(R.string.preferences_has_media_permission), v) + .apply() + } + + fun hasAllowMedia(): Boolean { + return activity.getSharedPreferences(activity.getString(R.string.preferences_key), 0) + .getBoolean(activity.getString(R.string.preferences_allow_media), false) + } + + fun setAllowMedia(v: Boolean) { + activity.getSharedPreferences(activity.getString(R.string.preferences_key), 0).edit() + .putBoolean(activity.getString(R.string.preferences_allow_media), v) + .apply() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/SecureStorage.kt b/android/app/src/main/java/gallery/memories/service/SecureStorage.kt new file mode 100644 index 00000000..b5c3bdc9 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/SecureStorage.kt @@ -0,0 +1,95 @@ +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.KeyProperties.KEY_ALGORITHM_AES +import android.security.keystore.KeyProperties.PURPOSE_DECRYPT +import android.security.keystore.KeyProperties.PURPOSE_ENCRYPT +import android.util.Base64 +import gallery.memories.service.Credential +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec + +class SecureStorage(private val context: Context) { + + private val keyStore = KeyStore.getInstance("AndroidKeyStore") + private val keyAlias = "MemoriesKey" + + init { + keyStore.load(null) + if (!keyStore.containsAlias(keyAlias)) { + generateNewKey() + } + } + + fun saveCredentials(cred: Credential) { + val cipher = getCipher() + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + val encryptedToken = cipher.doFinal(cred.token.toByteArray()) + + context.getSharedPreferences("credentials", Context.MODE_PRIVATE).edit() + .putString("url", cred.url) + .putBoolean("trustAll", cred.trustAll) + .putString("username", cred.username) + .putString("encryptedToken", Base64.encodeToString(encryptedToken, Base64.DEFAULT)) + .putString("iv", Base64.encodeToString(cipher.iv, Base64.DEFAULT)) + .apply() + } + + fun getCredentials(): Credential? { + val sharedPreferences = context.getSharedPreferences("credentials", Context.MODE_PRIVATE) + + val url = sharedPreferences.getString("url", null) + val trustAll = sharedPreferences.getBoolean("trustAll", false) + val username = sharedPreferences.getString("username", null) + val encryptedToken = sharedPreferences.getString("encryptedToken", null) + val ivStr = sharedPreferences.getString("iv", null) + + if (url != null && username != null && encryptedToken != null && ivStr != null) { + val cipher = getCipher() + val iv = Base64.decode(ivStr, Base64.DEFAULT) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), IvParameterSpec(iv)) + val token = String(cipher.doFinal(Base64.decode(encryptedToken, Base64.DEFAULT))) + return Credential(url, trustAll, username, token) + } + + return null + } + + fun deleteCredentials() { + context.getSharedPreferences("credentials", Context.MODE_PRIVATE).edit() + .remove("url") + .remove("trustAll") + .remove("encryptedUsername") + .remove("encryptedToken") + .remove("iv") + .apply() + } + + private fun generateNewKey() { + val keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore") + val keyGenSpec = KeyGenParameterSpec.Builder( + keyAlias, + PURPOSE_ENCRYPT or PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setUserAuthenticationRequired(false) // Change this if needed + .build() + + keyGenerator.init(keyGenSpec) + keyGenerator.generateKey() + } + + private fun getCipher(): Cipher { + val transformation = + "$KEY_ALGORITHM_AES/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}" + return Cipher.getInstance(transformation) + } + + private fun getSecretKey(): SecretKey { + return keyStore.getKey(keyAlias, null) as SecretKey + } +} \ No newline at end of file diff --git a/android/app/src/main/java/gallery/memories/service/TimelineQuery.kt b/android/app/src/main/java/gallery/memories/service/TimelineQuery.kt new file mode 100644 index 00000000..6da1f148 --- /dev/null +++ b/android/app/src/main/java/gallery/memories/service/TimelineQuery.kt @@ -0,0 +1,461 @@ +package gallery.memories.service + +import android.annotation.SuppressLint +import android.app.Activity +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.exifinterface.media.ExifInterface +import androidx.media3.common.util.UnstableApi +import gallery.memories.MainActivity +import gallery.memories.R +import gallery.memories.dao.AppDatabase +import gallery.memories.mapper.Fields +import gallery.memories.mapper.Response +import gallery.memories.mapper.SystemImage +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.time.Instant +import java.util.concurrent.CountDownLatch + +@UnstableApi +class TimelineQuery(private val mCtx: MainActivity) { + private val TAG = TimelineQuery::class.java.simpleName + private val mConfigService = ConfigService(mCtx) + + // Database + private val mDb = AppDatabase.get(mCtx) + private val mPhotoDao = mDb.photoDao() + + // Photo deletion events + var deleting = false + var deleteIntentLauncher: ActivityResultLauncher + var deleteCallback: ((ActivityResult?) -> Unit)? = null + + // Observers + var imageObserver: ContentObserver? = null + var videoObserver: ContentObserver? = null + var refreshPending: Boolean = false + + // Status of synchronization process + // -1 = not started + // >0 = number of files updated + var syncStatus = -1 + + init { + // Register intent launcher for callback + deleteIntentLauncher = + mCtx.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult? -> + synchronized(this) { + deleteCallback?.let { it(result) } + } + } + } + + /** + * Initialize content observers for system store. + * Runs the first sync pass. + */ + fun initialize() { + mPhotoDao.ping() + if (syncDeltaDb() > 0) { + mCtx.refreshTimeline() + } + registerHooks() + } + + /** + * Destroy content observers for system store. + */ + fun destroy() { + if (imageObserver != null) + mCtx.contentResolver.unregisterContentObserver(imageObserver!!) + if (videoObserver != null) + mCtx.contentResolver.unregisterContentObserver(videoObserver!!) + } + + /** + * Register content observers for system store. + */ + fun registerHooks() { + imageObserver = registerContentObserver(SystemImage.IMAGE_URI) + videoObserver = registerContentObserver(SystemImage.VIDEO_URI) + } + + /** + * Register content observer for system store. + * @param uri Content URI + * @return Content observer + */ + private fun registerContentObserver(uri: Uri): ContentObserver { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + super.onChange(selfChange) + + // Debounce refreshes + synchronized(this@TimelineQuery) { + if (refreshPending) return + refreshPending = true + } + + // Refresh after 750ms + Thread { + Thread.sleep(750) + synchronized(this@TimelineQuery) { + refreshPending = false + } + + // Check if anything to update + if (syncDeltaDb() == 0 || mCtx.isDestroyed || mCtx.isFinishing) return@Thread + + mCtx.refreshTimeline() + }.start() + } + } + + mCtx.contentResolver.registerContentObserver(uri, true, observer) + return observer + } + + /** + * Get system images by AUIDs + * @param auids List of AUIDs + * @return List of SystemImage + */ + fun getSystemImagesByAUIDs(auids: List): List { + val photos = mPhotoDao.getPhotosByAUIDs(auids) + if (photos.isEmpty()) return listOf() + return SystemImage.getByIds(mCtx, photos.map { it.localId }) + } + + /** + * Get the days response for local files. + * @return JSON response + */ + @Throws(JSONException::class) + fun getDays(): JSONArray { + return mPhotoDao.getDays(mConfigService.enabledBucketIds).map { + JSONObject() + .put(Fields.Day.DAYID, it.dayId) + .put(Fields.Day.COUNT, it.count) + }.let { JSONArray(it) } + } + + /** + * Get the day response for local files. + * @param dayId Day ID + * @return JSON response + */ + @Throws(JSONException::class) + fun getDay(dayId: Long): JSONArray { + // Get the photos for the day from DB + val photos = mPhotoDao.getPhotosByDay(dayId, mConfigService.enabledBucketIds) + .map { it.localId to it }.toMap() + + if (photos.isEmpty()) return JSONArray() + val fileIds = photos.keys.toMutableList() + + // Get latest metadata from system table + val response = SystemImage.getByIds(mCtx, fileIds).map { image -> + // Mark file exists + fileIds.remove(image.fileId) + + // Add missing fields to JSON + val json = image.json + photos[image.fileId]?.let { photo -> + json.put(Fields.Photo.AUID, photo.auid) + .put(Fields.Photo.BUID, photo.buid) + .put(Fields.Photo.DAYID, dayId) + } + + json + }.let { JSONArray(it) } + + // Remove files that were not found + mPhotoDao.deleteFileIds(fileIds) + + return response + } + + /** + * Get the image EXIF info response for local files. + * @param id File ID + * @return JSON response + */ + @Throws(Exception::class) + fun getImageInfo(id: Long): JSONObject { + val photos = mPhotoDao.getPhotosByFileIds(listOf(id)) + if (photos.isEmpty()) throw Exception("File not found in database") + + // Get image from system table + val images = SystemImage.getByIds(mCtx, listOf(id)) + if (images.isEmpty()) throw Exception("File not found in system") + + // Get the photo and image + val photo = photos[0] + val image = images[0]; + + // Augment image JSON with database info + val obj = image.json + .put(Fields.Photo.DAYID, photo.dayId) + .put(Fields.Photo.DATETAKEN, photo.dateTaken) + .put(Fields.Photo.PERMISSIONS, Fields.Perm.DELETE) + + try { + val exif = ExifInterface(image.dataPath) + obj.put(Fields.Photo.EXIF, JSONObject().apply { + Fields.EXIF.MAP.forEach { (key, field) -> + put(field, exif.getAttribute(key)) + } + }) + } catch (e: IOException) { + Log.w(TAG, "Error reading EXIF data for $id") + } + + return obj + + } + + /** + * Delete images from local database and system store. + * @param auids List of AUIDs + * @param dry Dry run (returns whether confirmation will be needed) + * @return JSON response + */ + @Throws(Exception::class) + fun delete(auids: List, dry: Boolean): JSONObject { + synchronized(this) { + if (deleting) throw Exception("Already deleting another set of images") + deleting = true + } + + val response = Response.OK + + try { + // Get list of file IDs + val sysImgs = getSystemImagesByAUIDs(auids) + + // Let the UI know how many files we are deleting + response.put("count", sysImgs.size) + // Let the UI know if we are going to ask for confirmation + response.put("confirms", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + + // Exit if dry or nothing to do + if (dry || sysImgs.isEmpty()) return response + + // List of URIs + val uris = sysImgs.map { it.uri } + if (uris.isEmpty()) return Response.OK + + // Delete file with media store + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = MediaStore.createTrashRequest(mCtx.contentResolver, uris, true) + deleteIntentLauncher.launch( + IntentSenderRequest.Builder(intent.intentSender).build() + ) + + // Wait for response + val latch = CountDownLatch(1) + var res: ActivityResult? = null + deleteCallback = fun(result: ActivityResult?) { + res = result + latch.countDown() + } + latch.await() + deleteCallback = null; + + // Throw if canceled or failed + if (res == null || res!!.resultCode != Activity.RESULT_OK) { + throw Exception("Delete canceled or failed") + } + } else { + for (uri in uris) { + mCtx.contentResolver.delete(uri, null, null) + } + } + + // Delete from database + mPhotoDao.deleteFileIds(sysImgs.map { it.fileId }) + + // Clear UI cache + mCtx.busEmit("nativex:db:updated") + } finally { + synchronized(this) { deleting = false } + } + + return response + } + + /** + * Sync local database with system store. + * @param startTime Only sync files modified after this time + * @return Number of updated files + */ + private fun syncDb(startTime: Long): Int { + // Date modified is in seconds, not millis + val syncTime = Instant.now().toEpochMilli() / 1000; + + // SystemImage query + var selection: String? = null + var selectionArgs: Array? = null + + // Query everything modified after startTime + if (startTime != 0L) { + selection = MediaStore.Images.Media.DATE_MODIFIED + " > ?" + selectionArgs = arrayOf(startTime.toString()) + } + + // Count number of updates + var updates = 0 + + try { + // Iterate all images from system store + for (image in SystemImage.cursor( + mCtx, + SystemImage.IMAGE_URI, + selection, + selectionArgs, + null + )) { + insertItemDb(image) + updates++ + syncStatus = updates + } + + // Iterate all videos from system store + for (video in SystemImage.cursor( + mCtx, + SystemImage.VIDEO_URI, + selection, + selectionArgs, + null + )) { + insertItemDb(video) + updates++ + syncStatus = updates + } + + // Store last sync time + mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0).edit() + .putLong(mCtx.getString(R.string.preferences_last_sync_time), syncTime) + .apply() + } catch (e: Exception) { + Log.e(TAG, "Error syncing database", e) + } + + // Reset sync status + synchronized(this) { + syncStatus = -1 + } + + // Number of updated files + return updates + } + + /** + * Sync local database with system store. + * @return Number of updated files + */ + fun syncDeltaDb(): Int { + // Exit if already running + synchronized(this) { + if (syncStatus != -1) return 0 + syncStatus = 0 + } + + // Get last sync time + val syncTime = mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0) + .getLong(mCtx.getString(R.string.preferences_last_sync_time), 0L) + return syncDb(syncTime) + } + + /** + * Sync local database with system store. + * Runs a full synchronization pass, flagging all files for removal. + * @return Number of updated files + */ + fun syncFullDb() { + // Exit if already running + synchronized(this) { + if (syncStatus != -1) return + syncStatus = 0 + } + + // Flag all images for removal + mPhotoDao.flagAll() + + // Sync all files, marking them in the process + syncDb(0L) + + // Clean up stale files + mPhotoDao.deleteFlagged() + } + + /** + * Insert item into local database. + * @param image SystemImage + */ + @SuppressLint("SimpleDateFormat") + private fun insertItemDb(image: SystemImage) { + val fileId = image.fileId + val baseName = image.baseName + + // Check if file with local_id and mtime already exists + val l = mPhotoDao.getPhotosByFileIds(listOf(fileId)) + if (!l.isEmpty() && l[0].mtime == image.mtime) { + // File already exists, remove flag + mPhotoDao.unflag(fileId) + Log.v(TAG, "File already exists: $fileId / $baseName") + return + } + + // Convert to photo + val photo = image.photo + + // Delete file with same local_id and insert new one + mPhotoDao.deleteFileIds(listOf(fileId)) + mPhotoDao.insert(photo) + Log.v(TAG, "Inserted file to local DB: $photo") + } + + /** + * Set has_remote for list of AUIDs + * @param auids List of AUIDs + * @param value Value to set + */ + fun setHasRemote(auids: List, buids: List, value: Boolean) { + mPhotoDao.setHasRemote(auids, buids, value) + } + + /** + * Active local folders response. + * This is in timeline query because it calls the database service. + */ + var localFolders: JSONArray + get() { + return mPhotoDao.getBuckets().map { + JSONObject() + .put(Fields.Bucket.ID, it.id) + .put(Fields.Bucket.NAME, it.name) + .put(Fields.Bucket.ENABLED, mConfigService.enabledBucketIds.contains(it.id)) + }.let { JSONArray(it) } + } + set(value) { + val enabled = mutableListOf() + for (i in 0 until value.length()) { + val obj = value.getJSONObject(i) + if (obj.getBoolean(Fields.Bucket.ENABLED)) { + enabled.add(obj.getString(Fields.Bucket.ID)) + } + } + mConfigService.enabledBucketIds = enabled + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..7dbe77dc --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..345888d2 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..0898dcbb Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..248fdfb1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..317ef0d5 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..317ef0d5 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..137cdf2e Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..c5c23c2b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..66383f35 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..66383f35 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..b2ac9e1a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..bab97f42 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3e5a8356 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..3e5a8356 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..c84a3523 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..b7d2c677 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3c3663d8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..3c3663d8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..dc98f2a0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..060cb6d7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..4b29079d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..4b29079d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/values-land/dimens.xml b/android/app/src/main/res/values-land/dimens.xml new file mode 100644 index 00000000..22d7f004 --- /dev/null +++ b/android/app/src/main/res/values-land/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..e3be601f --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values-w1240dp/dimens.xml b/android/app/src/main/res/values-w1240dp/dimens.xml new file mode 100644 index 00000000..d73f4a35 --- /dev/null +++ b/android/app/src/main/res/values-w1240dp/dimens.xml @@ -0,0 +1,3 @@ + + 200dp + \ No newline at end of file diff --git a/android/app/src/main/res/values-w600dp/dimens.xml b/android/app/src/main/res/values-w600dp/dimens.xml new file mode 100644 index 00000000..22d7f004 --- /dev/null +++ b/android/app/src/main/res/values-w600dp/dimens.xml @@ -0,0 +1,3 @@ + + 48dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..c9c0c57f --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #2b94f0 + \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..125df871 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 16dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..f0f614dd --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + Memories + 5.4.2 + + memories + themeColor + themeDark + lastDbSyncTime + hasMediaPermission + allowMedia + enabledLocalFolders + + + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.76 Mobile Safari/537.36" + MemoriesNative/ + + Your server does not have the minimum required version of Memories + Logged out from server + Failed to connect to server. Reset app data if this persists. + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..b10c0df9 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + +