Add 'android/' from commit 'c3e1cb338b8124ed3af6c3ae51ae3927a0086f22'
git-subtree-dir: android git-subtree-mainline:pull/653/mergee95a7e022c
git-subtree-split:c3e1cb338b
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
|
@ -0,0 +1 @@
|
|||
Memories
|
|
@ -0,0 +1,123 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="C:\Users\varun\.android\avd\Pixel_6_API_33_2.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2023-05-16T08:14:59.213052500Z" />
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0" />
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,14 @@
|
|||
<project version="4">
|
||||
<component name="EntryPointsManager">
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="android.webkit.JavascriptInterface" />
|
||||
</list>
|
||||
</component>
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_DOWNLOAD_MANAGER" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Memories"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Memories.NoActionBar"
|
||||
android:configChanges="orientation|screenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".service.DownloadBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,71 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg width="47.276897mm" height="12.685879mm" viewBox="0 0 47.276898 12.685879" version="1.1" id="svg5"
|
||||
sodipodi:docname="memories-title (1).svg" inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview14"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.2378992"
|
||||
inkscape:cx="85.225033"
|
||||
inkscape:cy="88.456314"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="991"
|
||||
inkscape:window-x="1911"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg5" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-57.784684,-63.463599)"
|
||||
style="stroke:none;stroke-opacity:1;fill:white;fill-opacity:1">
|
||||
<g
|
||||
aria-label="Memories"
|
||||
id="text1574"
|
||||
style="font-size:14.1111px;line-height:1.25;font-family:monospace;-inkscape-font-specification:monospace;stroke-width:0.264583;stroke:none;stroke-opacity:1;fill:white;fill-opacity:1">
|
||||
<path
|
||||
d="m 67.718898,66.850263 c 0,2.328331 -1.467554,4.797774 -1.467554,7.224883 0,1.41111 0.860777,2.074332 1.622776,2.074332 2.031999,0 3.400776,-4.600219 3.781775,-5.870218 0.07056,-0.211667 -0.02822,-0.282222 -0.268111,-0.282222 -0.197555,0 -0.225777,0.05644 -0.296333,0.239889 -0.634999,1.523999 -2.003776,4.938885 -2.850442,4.938885 -0.211666,0 -0.592666,-0.197556 -0.592666,-1.001888 0,-2.356554 1.439332,-4.23333 1.439332,-7.027328 0,-0.719666 -0.141111,-1.79211 -1.142999,-1.79211 -1.326444,0 -2.906887,1.989665 -3.58422,3.443108 0.225778,-0.917221 0.719667,-2.652886 0.719667,-3.711219 0,-0.973666 -0.409222,-1.622776 -1.030111,-1.622776 -1.693332,0 -3.739441,5.26344 -4.388552,6.646328 0.296333,-1.185333 1.298221,-4.684885 1.298221,-5.602107 0,-0.493888 -0.268111,-0.931333 -0.578555,-0.931333 -0.437444,0 -0.917221,0.917222 -1.312332,2.539998 -0.239889,0.945444 -0.649111,3.005665 -0.917222,4.656664 -0.225777,1.396998 -0.366888,2.046109 -0.366888,2.666997 0,0.381 0.183444,0.437445 0.437444,0.437445 0.310444,0 0.536222,-0.08467 0.747888,-0.282222 0.578555,-0.550333 0.620889,-1.622777 0.917222,-2.483554 0.592666,-1.707443 3.062108,-6.180662 3.612441,-6.180662 0.127,0 0.127,0.239889 0.127,0.282222 0,1.636888 -1.241777,4.628441 -1.241777,6.632217 0,0 0,0.409222 0.352778,0.409222 0.155222,0 0.352777,-0.07055 0.522111,-0.197555 0.338666,-0.254 0.409222,-0.592667 0.606777,-1.086555 0.818444,-2.116665 2.82222,-4.571997 3.59833,-4.571997 0.268111,0 0.254,0.239889 0.254,0.451556 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7728" />
|
||||
<path
|
||||
d="m 71.980438,73.016813 c 1.396999,0 3.033887,-1.693332 3.033887,-2.808109 0,-0.04233 0,-0.07055 -0.01411,-0.112888 -0.07056,-0.08467 -0.155222,-0.141111 -0.268111,-0.141111 -0.366889,0.578555 -1.53811,2.257776 -2.31422,2.257776 -0.437445,0 -0.620889,-0.550333 -0.620889,-0.917222 v -0.07055 c 0.987777,-0.05644 1.91911,-1.298222 1.91911,-2.243665 0,-0.550333 -0.296333,-0.860777 -0.860777,-0.860777 -1.523999,0 -2.314221,1.93322 -2.314221,3.217331 0,0.705555 0.578555,1.67922 1.439332,1.67922 z m 1.030111,-3.880552 c 0,0.395111 -0.719666,1.495777 -1.15711,1.523999 0.02822,-0.381 0.52211,-1.834443 1.001888,-1.834443 0.155222,0 0.155222,0.211666 0.155222,0.310444 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7730" />
|
||||
<path
|
||||
d="m 75.804523,67.301818 c -1.128888,0 -1.467555,3.838219 -1.467555,4.727218 0,0.352778 0.02822,0.987777 0.508,0.987777 0.790222,0 1.213555,-0.733777 1.425221,-1.396998 0.169333,-0.550333 1.086555,-3.612442 1.552221,-3.668887 0.127,0.197556 0.127,0.635 0.127,0.874889 0,0.888999 -0.08467,1.763887 -0.08467,2.652887 0,0.324555 -0.01411,1.015999 0.465666,1.015999 0.705555,0 1.890887,-3.824108 2.511776,-3.922886 0.07055,0.183444 0.08467,0.366888 0.08467,0.550333 0,1.058332 -0.296333,2.074332 -0.296333,3.132664 0,0.465666 0.05644,1.368777 0.705555,1.368777 0.606777,0 2.610554,-2.892776 2.69522,-3.527775 -0.05644,-0.08467 -0.169333,-0.141111 -0.268111,-0.141111 -0.197555,0 -1.665109,2.342442 -2.046109,2.398887 -0.04233,-0.02822 -0.05644,-0.08467 -0.05644,-0.112889 0,-0.381 0.155223,-0.818444 0.239889,-1.199444 0.169333,-0.761999 0.338667,-1.552221 0.338667,-2.342442 0,-0.508 -0.254,-1.255888 -0.874889,-1.255888 -1.100665,0 -2.017887,1.947332 -2.427109,2.793998 0.01411,-0.578555 0.112889,-1.157111 0.112889,-1.749777 0,-0.564444 -0.05644,-1.66511 -0.846666,-1.66511 -1.171221,0 -2.158998,2.920998 -2.539998,3.83822 0.08467,-0.931333 0.508,-1.820332 0.508,-2.765776 0,-0.282222 0.01411,-0.592666 -0.366889,-0.592666 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7732" />
|
||||
<path
|
||||
d="m 84.652174,74.046924 c 1.058333,0 1.975554,-1.086555 2.384776,-2.412998 0.860777,-0.09878 2.04611,-0.959555 2.04611,-1.495777 0,-0.127 -0.07056,-0.254 -0.169334,-0.282222 -0.437444,0.352777 -1.199443,0.776111 -1.679221,0.90311 0.02822,-0.183444 0.02822,-0.366888 0.02822,-0.550333 0,-1.396998 -0.747889,-2.652886 -1.594555,-2.652886 -0.691444,0 -1.467554,0.832555 -1.467554,1.566332 0,0.02822 0,0.07055 0,0.09878 -0.691444,0.564444 -1.086555,1.495777 -1.086555,2.539999 0,1.326443 0.649111,2.285998 1.53811,2.285998 z m 1.749777,-3.443109 c -0.606778,-0.338666 -1.284111,-1.044221 -1.284111,-1.467554 0,-0.254 0.268111,-0.550333 0.522111,-0.550333 0.409222,0 0.776111,0.804333 0.776111,1.693332 0,0.112889 0,0.211667 -0.01411,0.324555 z m -0.155223,0.917222 c -0.239888,0.90311 -0.691443,1.679221 -1.227665,1.679221 -0.381,0 -0.733777,-0.620889 -0.733777,-1.298221 0,-0.536222 0.225777,-1.213555 0.465666,-1.523999 0.395111,0.493888 0.959555,0.931332 1.495776,1.142999 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7734" />
|
||||
<path
|
||||
d="m 89.986155,74.752479 c 1.001888,0 3.570108,-3.697108 3.640664,-4.656663 0.01411,-0.155222 -0.155222,-0.141111 -0.268111,-0.141111 -0.254,0.395111 -2.300109,3.485441 -2.737553,3.485441 -0.09878,0 -0.08467,-0.141111 -0.08467,-0.211666 0.07056,-0.790222 0.705555,-1.594554 0.776111,-2.427109 0.08467,-0.874889 -0.606778,-0.889 -1.312333,-1.128888 0.338667,-0.310445 0.649111,-0.917222 0.691444,-1.354666 0.04233,-0.522111 -0.268111,-1.326443 -0.874888,-1.326443 -1.001888,0 -1.368777,1.015999 -1.439332,1.834443 -0.112889,1.41111 0.931332,1.509887 1.961443,1.834443 -0.01411,0.239888 -0.352778,0.945443 -0.465667,1.227665 -0.225777,0.606778 -0.451555,1.241777 -0.507999,1.876777 -0.04233,0.507999 0.01411,0.987777 0.620888,0.987777 z m -0.155222,-6.349995 c -0.02822,0.282222 -0.169333,0.931332 -0.381,1.128888 -0.211666,-0.112889 -0.239888,-0.479778 -0.225777,-0.705555 0.02822,-0.183445 0.112889,-0.959555 0.395111,-0.959555 0.197555,0 0.225777,0.395111 0.211666,0.536222 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7736" />
|
||||
<path
|
||||
d="m 94.473479,67.555818 c 0.479778,0 0.719666,-0.550333 0.719666,-0.959555 0,-0.282222 -0.09878,-0.649111 -0.451555,-0.649111 -0.578555,0 -0.804332,0.508 -0.804332,0.945444 0,0.296333 0.211666,0.663222 0.536221,0.663222 z m -0.550333,5.64444 c 0.860778,0 2.568221,-2.328332 2.568221,-3.033887 0,-0.127 -0.02822,-0.211666 -0.169334,-0.211666 -0.52211,0 -1.636887,2.257776 -2.031998,2.257776 -0.211666,0 -0.225778,-0.324556 -0.225778,-0.465667 0,-1.721554 0.677333,-2.356553 0.677333,-2.977442 0,-0.366888 -0.211666,-0.451555 -0.550333,-0.451555 -0.733777,0 -1.213554,1.834443 -1.213554,3.033887 0,0.592666 0.141111,1.848554 0.945443,1.848554 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7738" />
|
||||
<path
|
||||
d="m 97.098125,73.016813 c 1.396999,0 3.033885,-1.693332 3.033885,-2.808109 0,-0.04233 0,-0.07055 -0.0141,-0.112888 -0.0706,-0.08467 -0.155222,-0.141111 -0.268111,-0.141111 -0.366888,0.578555 -1.53811,2.257776 -2.31422,2.257776 -0.437444,0 -0.620889,-0.550333 -0.620889,-0.917222 v -0.07055 c 0.987777,-0.05644 1.91911,-1.298222 1.91911,-2.243665 0,-0.550333 -0.296333,-0.860777 -0.860777,-0.860777 -1.523999,0 -2.31422,1.93322 -2.31422,3.217331 0,0.705555 0.578555,1.67922 1.439332,1.67922 z m 1.03011,-3.880552 c 0,0.395111 -0.719666,1.495777 -1.15711,1.523999 0.02822,-0.381 0.522111,-1.834443 1.001888,-1.834443 0.155222,0 0.155222,0.211666 0.155222,0.310444 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7740" />
|
||||
<path
|
||||
d="m 101.00688,72.043148 c -0.95956,0.733777 -1.693333,1.580443 -1.693333,2.328331 0,0.832555 0.634999,1.439332 1.453443,1.439332 1.27,0 2.65289,-1.213554 2.65289,-2.356553 0,-0.776111 -0.31045,-1.298222 -0.73378,-1.693332 0.94544,-0.578556 1.905,-1.100666 2.37066,-1.523999 0.0282,-0.141111 -0.0705,-0.296333 -0.21166,-0.268111 -0.52211,0.127 -1.651,0.634999 -2.75167,1.326443 -0.67733,-0.465666 -1.35466,-0.77611 -1.35466,-1.382888 0,-0.592666 0.60678,-1.707443 1.28411,-1.707443 0.32455,0 0.55033,0.197556 0.55033,0.592666 0,0.324556 -0.16933,0.620889 -0.16933,0.945444 0,0.183444 0.11289,0.324555 0.29633,0.324555 0.508,0 0.80433,-0.64911 0.80433,-1.072443 0,-0.973666 -0.52211,-1.495777 -1.49577,-1.495777 -1.43934,0 -2.441224,1.185333 -2.441224,2.582332 0,1.086554 0.719664,1.566332 1.439334,1.961443 z m -0.69145,2.116665 c 0,-0.578556 0.59267,-1.171222 1.36878,-1.735666 0.42333,0.268111 0.74789,0.578555 0.74789,1.100666 0,0.522111 -0.74789,1.326443 -1.397,1.326443 -0.42333,0 -0.71967,-0.324555 -0.71967,-0.691443 z"
|
||||
style="font-family:'Lofty Goals';-inkscape-font-specification:'Lofty Goals';stroke:none;stroke-opacity:1;fill:white;fill-opacity:1"
|
||||
id="path7742" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 10 KiB |
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Memories</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="container">
|
||||
<img src="memories.svg" alt="Memories Logo" class="logo" />
|
||||
<p id="waiting">
|
||||
Waiting for login to complete <br />
|
||||
Keep this page open in the background
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,136 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Memories</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="container animatable invisible">
|
||||
<img src="memories.svg" alt="Memories Logo" class="logo" />
|
||||
<p>
|
||||
Start organizing and sharing your precious moments. Enter the address of
|
||||
your Nextcloud server to begin.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
id="server-url"
|
||||
class="m-input"
|
||||
placeholder="nextcloud.example.com"
|
||||
/>
|
||||
|
||||
<div class="trust">
|
||||
<label for="trust-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trust-all"
|
||||
class="m-checkbox"
|
||||
/>
|
||||
Disable certificate verification (unsafe)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="m-button login-button" id="login">
|
||||
Continue to Login
|
||||
</button>
|
||||
<br />
|
||||
|
||||
<a class="m-button link" href="https://memories.gallery/install/">
|
||||
I don't have a server
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const urlBox = document.getElementById("server-url");
|
||||
const loginButton = document.getElementById("login");
|
||||
|
||||
function validateUrl(url) {
|
||||
try {
|
||||
url = new URL(url);
|
||||
const protoOk = url.protocol === "http:" || url.protocol === "https:";
|
||||
const hostOk = url.hostname.length > 0;
|
||||
return protoOk && hostOk;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUrl() {
|
||||
const url = urlBox.value.toLowerCase();
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return "https://" + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function updateLoginEnabled() {
|
||||
loginButton.disabled = !validateUrl(getUrl());
|
||||
}
|
||||
|
||||
function getMemoriesUrl() {
|
||||
const url = new URL(getUrl());
|
||||
|
||||
// Add trailing slash to the path if it's not there already
|
||||
if (!url.pathname.endsWith("/")) {
|
||||
url.pathname += "/";
|
||||
}
|
||||
|
||||
// Add index.php to the path if it's not there already
|
||||
if (!url.pathname.includes("index.php")) {
|
||||
url.pathname += "index.php/";
|
||||
}
|
||||
|
||||
// Add path to memories
|
||||
url.pathname += "apps/memories/";
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Update login button enabled state when the URL changes
|
||||
urlBox.addEventListener("input", updateLoginEnabled);
|
||||
updateLoginEnabled();
|
||||
|
||||
// Login button click handler
|
||||
loginButton.addEventListener("click", async () => {
|
||||
try {
|
||||
urlBox.disabled = true;
|
||||
loginButton.disabled = true;
|
||||
|
||||
// Abort request after 5 seconds
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
// Login signal
|
||||
const encUrl = encodeURIComponent(encodeURIComponent(getMemoriesUrl().toString()));
|
||||
|
||||
// Trust all certificates
|
||||
const trustAll = document.getElementById("trust-all").checked ? "1" : "0";
|
||||
|
||||
await fetch(`http://127.0.0.1/api/login/${encUrl}?trustAll=${trustAll}`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// API is fine, redirect to login page
|
||||
clearTimeout(timeoutId);
|
||||
} catch (e) {
|
||||
// unreachable?
|
||||
} finally {
|
||||
urlBox.disabled = false;
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set action bar color
|
||||
const themeColor = getComputedStyle(
|
||||
document.documentElement
|
||||
).getPropertyValue("--theme-color");
|
||||
globalThis.nativex?.setThemeColor(themeColor, true);
|
||||
|
||||
// Make container visible
|
||||
document.getElementById("main").classList.remove("invisible");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -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<Uri>? = 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<Uri>, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String>): List<Day>
|
||||
|
||||
@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<String>): List<Photo>
|
||||
|
||||
@Query("DELETE FROM photos WHERE local_id IN (:fileIds)")
|
||||
fun deleteFileIds(fileIds: List<Long>)
|
||||
|
||||
@Query("SELECT * FROM photos WHERE local_id IN (:fileIds)")
|
||||
fun getPhotosByFileIds(fileIds: List<Long>): List<Photo>
|
||||
|
||||
@Query("SELECT * FROM photos WHERE auid IN (:auids)")
|
||||
fun getPhotosByAUIDs(auids: List<String>): List<Photo>
|
||||
|
||||
@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<Bucket>
|
||||
|
||||
@Query("UPDATE photos SET has_remote=:v WHERE auid IN (:auids) OR buid IN (:buids)")
|
||||
fun setHasRemote(auids: List<String>, buids: List<String>, v: Boolean)
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String>? - selection arguments
|
||||
* @param sortOrder String? - sort order
|
||||
* @return Sequence<SystemImage>
|
||||
*/
|
||||
fun cursor(
|
||||
ctx: Context,
|
||||
collection: Uri,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
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<Long> - list of IDs
|
||||
* @return List<SystemImage>
|
||||
*/
|
||||
fun getByIds(ctx: Context, ids: List<Long>): List<SystemImage> {
|
||||
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')
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String>? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of enabled local folders
|
||||
* @return The list of enabled local folders
|
||||
*/
|
||||
var enabledBucketIds: List<String>
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package gallery.memories.service
|
||||
|
||||
data class Credential(
|
||||
var url: String,
|
||||
var trustAll: Boolean,
|
||||
var username: String,
|
||||
var token: String,
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<Long, () -> 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
|
||||
}
|
||||
}
|
|
@ -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<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String
|
||||
) {
|
||||
}
|
||||
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||
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<String, String>?) {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<Array<String>>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<IntentSenderRequest>
|
||||
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<String>): List<SystemImage> {
|
||||
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<String>, 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<String>? = 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<String>, buids: List<String>, 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<String>()
|
||||
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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/coordinator"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<androidx.media3.ui.PlayerView
|
||||
android:id="@+id/video_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:alpha="0.0"
|
||||
app:show_buffering="always"
|
||||
/>
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:soundEffectsEnabled="true"
|
||||
/>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
After Width: | Height: | Size: 6.7 KiB |
After Width: | Height: | Size: 857 B |
After Width: | Height: | Size: 9.7 KiB |
After Width: | Height: | Size: 9.7 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 463 B |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 9.9 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 25 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 37 KiB |
After Width: | Height: | Size: 37 KiB |
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
|
@ -0,0 +1,16 @@
|
|||
<resources>
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.Memories" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/theme</item>
|
||||
<item name="colorPrimaryVariant">@color/theme</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">200dp</dimen>
|
||||
</resources>
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="theme">#2b94f0</color>
|
||||
</resources>
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
</resources>
|
|
@ -0,0 +1,20 @@
|
|||
<resources>
|
||||
<string name="app_name">Memories</string>
|
||||
<string name="min_server_version">5.4.2</string>
|
||||
|
||||
<string name="preferences_key">memories</string>
|
||||
<string name="preferences_theme_color">themeColor</string>
|
||||
<string name="preferences_theme_dark">themeDark</string>
|
||||
<string name="preferences_last_sync_time">lastDbSyncTime</string>
|
||||
<string name="preferences_has_media_permission">hasMediaPermission</string>
|
||||
<string name="preferences_allow_media">allowMedia</string>
|
||||
<string name="preferences_enabled_local_folders">enabledLocalFolders</string>
|
||||
|
||||
<!-- https://www.whatismybrowser.com/guides/the-latest-user-agent/chrome -->
|
||||
<string name="ua_chrome">"Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.76 Mobile Safari/537.36"</string>
|
||||
<string name="ua_app_prefix">MemoriesNative/</string>
|
||||
|
||||
<string name="err_no_ver">Your server does not have the minimum required version of Memories</string>
|
||||
<string name="err_logged_out">Logged out from server</string>
|
||||
<string name="err_no_describe">Failed to connect to server. Reset app data if this persists.</string>
|
||||
</resources>
|
|
@ -0,0 +1,25 @@
|
|||
<resources>
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.Memories" parent="Theme.MaterialComponents.Light.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/theme</item>
|
||||
<item name="colorPrimaryVariant">@color/theme</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.Memories.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Memories.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
|
||||
|
||||
<style name="Theme.Memories.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
|
||||
</resources>
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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" "$@"
|
|
@ -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
|
|
@ -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'
|