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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
+ Start organizing and sharing your precious moments. Enter the address of
+ your Nextcloud server to begin.
+
+
+
+
+
+
+
+ Disable certificate verification (unsafe)
+
+
+
+
+ Continue to Login
+
+
+
+
+ I don't have a server
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 00000000..68e573d2
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,12 @@
+buildscript {
+ ext.kotlin_version = '1.9.0'
+ dependencies {
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+plugins {
+ id 'com.android.application' version '8.1.2' apply false
+ id 'com.android.library' version '8.1.2' apply false
+ id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 00000000..3a131cad
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=false
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..d7af25ff
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue May 02 23:46:18 PDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 00000000..4f906e0c
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# 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
+#
+# https://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.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 00000000..1bc11b91
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "Memories"
+include ':app'