pull/653/merge
Varun Patil 2023-10-01 19:37:35 -07:00
parent 0eee69bacb
commit 2f065e6d12
7 changed files with 197 additions and 65 deletions

View File

@ -24,21 +24,6 @@ import java.net.URLDecoder
val image = ImageService(mCtx, query) val image = ImageService(mCtx, query)
val account = AccountService(mCtx) val account = AccountService(mCtx)
object API {
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/\\d+(,\\d+)*$")
val IMAGE_PREVIEW = Regex("^/image/preview/\\d+$")
val IMAGE_FULL = Regex("^/image/full/\\d+$")
val SHARE_URL = Regex("^/api/share/url/.+$")
val SHARE_BLOB = Regex("^/api/share/blob/.+$")
val SHARE_LOCAL = Regex("^/api/share/local/\\d+$")
}
init { init {
dlService = DownloadService(mCtx, query) dlService = DownloadService(mCtx, query)
} }
@ -52,38 +37,19 @@ import java.net.URLDecoder
query.destroy() query.destroy()
} }
fun handleRequest(request: WebResourceRequest): WebResourceResponse { object API {
val path = request.url.path ?: return makeErrorResponse() val DAYS = Regex("^/api/days$")
val DAY = Regex("^/api/days/\\d+$")
val response = try { val IMAGE_INFO = Regex("^/api/image/info/\\d+$")
when (request.method) { val IMAGE_DELETE = Regex("^/api/image/delete/\\d+(,\\d+)*$")
"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)
makeErrorResponse()
}
// Allow CORS from all origins val IMAGE_PREVIEW = Regex("^/image/preview/\\d+$")
response.responseHeaders = mutableMapOf( val IMAGE_FULL = Regex("^/image/full/\\d+$")
"Access-Control-Allow-Origin" to "*",
"Access-Control-Allow-Headers" to "*"
)
// Cache image responses for 7 days val SHARE_URL = Regex("^/api/share/url/.+$")
if (path.matches(API.IMAGE_PREVIEW) || path.matches(API.IMAGE_FULL)) { val SHARE_BLOB = Regex("^/api/share/blob/.+$")
response.responseHeaders["Cache-Control"] = "max-age=604800" val SHARE_LOCAL = Regex("^/api/share/local/\\d+$")
}
return response
} }
@JavascriptInterface @JavascriptInterface
@ -188,6 +154,40 @@ import java.net.URLDecoder
return query.localFolders.toString() return query.localFolders.toString()
} }
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)
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) @Throws(Exception::class)
private fun routerGet(request: WebResourceRequest): WebResourceResponse { private fun routerGet(request: WebResourceRequest): WebResourceResponse {
val path = request.url.path ?: return makeErrorResponse() val path = request.url.path ?: return makeErrorResponse()
@ -196,7 +196,7 @@ import java.net.URLDecoder
return if (path.matches(API.DAYS)) { return if (path.matches(API.DAYS)) {
makeResponse(query.getDays()) makeResponse(query.getDays())
} else if (path.matches(API.DAY)) { } else if (path.matches(API.DAY)) {
makeResponse(query.getByDayId(parts[3].toLong())) makeResponse(query.getDay(parts[3].toLong()))
} else if (path.matches(API.IMAGE_INFO)) { } else if (path.matches(API.IMAGE_INFO)) {
makeResponse(query.getImageInfo(parts[4].toLong())) makeResponse(query.getImageInfo(parts[4].toLong()))
} else if (path.matches(API.IMAGE_DELETE)) { } else if (path.matches(API.IMAGE_DELETE)) {

View File

@ -26,12 +26,11 @@ class AccountService(private val mCtx: MainActivity) {
var authHeader: String? = null var authHeader: String? = null
var memoriesUrl: String? = null var memoriesUrl: String? = null
private fun toast(message: String) { /**
mCtx.runOnUiThread { * Login to a server
Toast.makeText(mCtx, message, Toast.LENGTH_LONG).show() * @param baseUrl The base URL of the server
} * @param loginFlowUrl The login flow URL
} */
fun login(baseUrl: String, loginFlowUrl: String) { fun login(baseUrl: String, loginFlowUrl: String) {
// Make POST request to login flow URL // Make POST request to login flow URL
val client = OkHttpClient() val client = OkHttpClient()
@ -84,6 +83,12 @@ class AccountService(private val mCtx: MainActivity) {
}.start() }.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) { private fun pollLogin(pollUrl: String, pollToken: String, baseUrl: String) {
mCtx.binding.webview.post { mCtx.binding.webview.post {
mCtx.binding.webview.loadUrl("file:///android_asset/sync.html") mCtx.binding.webview.loadUrl("file:///android_asset/sync.html")
@ -141,6 +146,10 @@ class AccountService(private val mCtx: MainActivity) {
} }
} }
/**
* Check if the credentials are valid and the server version is supported
* Makes a toast to the user if something is wrong
*/
fun checkCredentialsAndVersion() { fun checkCredentialsAndVersion() {
if (memoriesUrl == null) return if (memoriesUrl == null) return
@ -192,6 +201,9 @@ class AccountService(private val mCtx: MainActivity) {
} }
} }
/**
* Handle a logout. Delete the stored credentials and go back to the login screen.
*/
fun loggedOut() { fun loggedOut() {
toast(mCtx.getString(R.string.err_logged_out)) toast(mCtx.getString(R.string.err_logged_out))
deleteCredentials() deleteCredentials()
@ -200,6 +212,12 @@ class AccountService(private val mCtx: MainActivity) {
} }
} }
/**
* 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) { fun storeCredentials(url: String, user: String, password: String) {
mCtx.getSharedPreferences("credentials", 0).edit() mCtx.getSharedPreferences("credentials", 0).edit()
.putString("memoriesUrl", url) .putString("memoriesUrl", url)
@ -210,6 +228,10 @@ class AccountService(private val mCtx: MainActivity) {
setAuthHeader(Pair(user, password)) setAuthHeader(Pair(user, password))
} }
/**
* Get the stored credentials
* @return The stored credentials
*/
fun getCredentials(): Pair<String, String>? { fun getCredentials(): Pair<String, String>? {
val prefs = mCtx.getSharedPreferences("credentials", 0) val prefs = mCtx.getSharedPreferences("credentials", 0)
memoriesUrl = prefs.getString("memoriesUrl", null) memoriesUrl = prefs.getString("memoriesUrl", null)
@ -219,6 +241,9 @@ class AccountService(private val mCtx: MainActivity) {
return Pair(user, password) return Pair(user, password)
} }
/**
* Delete the stored credentials
*/
fun deleteCredentials() { fun deleteCredentials() {
authHeader = null authHeader = null
memoriesUrl = null memoriesUrl = null
@ -229,10 +254,17 @@ class AccountService(private val mCtx: MainActivity) {
.apply() .apply()
} }
/**
* Refresh the authorization header
*/
fun refreshAuthHeader() { fun refreshAuthHeader() {
setAuthHeader(getCredentials()) setAuthHeader(getCredentials())
} }
/**
* Set the authorization header
* @param credentials The credentials to use
*/
private fun setAuthHeader(credentials: Pair<String, String>?) { private fun setAuthHeader(credentials: Pair<String, String>?) {
if (credentials != null) { if (credentials != null) {
val auth = "${credentials.first}:${credentials.second}" val auth = "${credentials.first}:${credentials.second}"
@ -241,4 +273,14 @@ class AccountService(private val mCtx: MainActivity) {
} }
authHeader = null authHeader = null
} }
/**
* 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()
}
}
} }

View File

@ -8,6 +8,10 @@ class ConfigService(private val mCtx: Context) {
private var mEnabledBuckets: List<String>? = null private var mEnabledBuckets: List<String>? = null
} }
/**
* Get the list of enabled local folders
* @return The list of enabled local folders
*/
var enabledBucketIds: List<String> var enabledBucketIds: List<String>
get() { get() {
if (mEnabledBuckets != null) return mEnabledBuckets!! if (mEnabledBuckets != null) return mEnabledBuckets!!

View File

@ -3,9 +3,13 @@ package gallery.memories.service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.media3.common.util.UnstableApi
import gallery.memories.NativeX import gallery.memories.NativeX
class DownloadBroadcastReceiver : BroadcastReceiver() { @UnstableApi class DownloadBroadcastReceiver : BroadcastReceiver() {
/**
* Callback when download is complete
*/
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
NativeX.dlService?.runDownloadCallback(intent) NativeX.dlService?.runDownloadCallback(intent)
} }

View File

@ -9,11 +9,16 @@ import android.webkit.CookieManager
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.media3.common.util.UnstableApi
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
class DownloadService(private val mActivity: AppCompatActivity, private val query: TimelineQuery) { @UnstableApi class DownloadService(private val mActivity: AppCompatActivity, private val query: TimelineQuery) {
private val mDownloads: MutableMap<Long, () -> Unit> = ArrayMap() private val mDownloads: MutableMap<Long, () -> Unit> = ArrayMap()
/**
* Callback when download is complete
* @param intent The intent that triggered the callback
*/
fun runDownloadCallback(intent: Intent) { fun runDownloadCallback(intent: Intent) {
if (mActivity.isDestroyed) return if (mActivity.isDestroyed) return
@ -31,6 +36,12 @@ class DownloadService(private val mActivity: AppCompatActivity, private val quer
} }
} }
/**
* 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 { fun queue(url: String, filename: String): Long {
val uri = Uri.parse(url) val uri = Uri.parse(url)
val manager = mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val manager = mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@ -52,6 +63,11 @@ class DownloadService(private val mActivity: AppCompatActivity, private val quer
return manager.enqueue(request) 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 { fun shareUrl(url: String): Boolean {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain" intent.type = "text/plain"
@ -60,6 +76,11 @@ class DownloadService(private val mActivity: AppCompatActivity, private val quer
return true return true
} }
/**
* Share a URL as a blob
* @param url The URL to share
* @return True if the URL was shared
*/
@Throws(Exception::class) @Throws(Exception::class)
fun shareBlobFromUrl(url: String): Boolean { fun shareBlobFromUrl(url: String): Boolean {
val id = queue(url, "") val id = queue(url, "")
@ -81,6 +102,11 @@ class DownloadService(private val mActivity: AppCompatActivity, private val quer
return true 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) @Throws(Exception::class)
fun shareLocal(auid: Long): Boolean { fun shareLocal(auid: Long): Boolean {
val sysImgs = query.getSystemImagesByAUIDs(listOf(auid)) val sysImgs = query.getSystemImagesByAUIDs(listOf(auid))
@ -95,6 +121,11 @@ class DownloadService(private val mActivity: AppCompatActivity, private val quer
return true 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? { private fun getDownloadedFileURI(downloadId: Long): String? {
val downloadManager = val downloadManager =
mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager mActivity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager

View File

@ -6,9 +6,15 @@ import android.graphics.Bitmap
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.media3.common.util.UnstableApi
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
class ImageService(private val mCtx: Context, private val query: TimelineQuery) { @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) @Throws(Exception::class)
fun getPreview(id: Long): ByteArray { fun getPreview(id: Long): ByteArray {
val bitmap = val bitmap =
@ -36,6 +42,11 @@ class ImageService(private val mCtx: Context, private val query: TimelineQuery)
return stream.toByteArray() 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) @Throws(Exception::class)
fun getFull(auid: Long): ByteArray { fun getFull(auid: Long): ByteArray {
val sysImgs = query.getSystemImagesByAUIDs(listOf(auid)) val sysImgs = query.getSystemImagesByAUIDs(listOf(auid))

View File

@ -105,14 +105,36 @@ class TimelineQuery(private val mCtx: MainActivity) {
return observer return observer
} }
/**
* Get system images by AUIDs
* @param auids List of AUIDs
*/
fun getSystemImagesByAUIDs(auids: List<Long>): List<SystemImage> { fun getSystemImagesByAUIDs(auids: List<Long>): List<SystemImage> {
val photos = mPhotoDao.getPhotosByAUIDs(auids) val photos = mPhotoDao.getPhotosByAUIDs(auids)
if (photos.isEmpty()) return listOf() if (photos.isEmpty()) return listOf()
return SystemImage.getByIds(mCtx, photos.map { it.localId }) return SystemImage.getByIds(mCtx, photos.map { it.localId })
} }
/**
* Get the days response for local files.
* @return JSON response
*/
@Throws(JSONException::class) @Throws(JSONException::class)
fun getByDayId(dayId: Long): JSONArray { 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 // Get the photos for the day from DB
val fileIds = mPhotoDao.getPhotosByDay(dayId, mConfigService.enabledBucketIds) val fileIds = mPhotoDao.getPhotosByDay(dayId, mConfigService.enabledBucketIds)
.map { it.localId }.toMutableList() .map { it.localId }.toMutableList()
@ -133,15 +155,6 @@ class TimelineQuery(private val mCtx: MainActivity) {
return photos return photos
} }
@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) }
}
@Throws(Exception::class) @Throws(Exception::class)
fun getImageInfo(id: Long): JSONObject { fun getImageInfo(id: Long): JSONObject {
val photos = mPhotoDao.getPhotosByFileIds(listOf(id)) val photos = mPhotoDao.getPhotosByFileIds(listOf(id))
@ -176,6 +189,12 @@ class TimelineQuery(private val mCtx: MainActivity) {
} }
/**
* 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) @Throws(Exception::class)
fun delete(auids: List<Long>, dry: Boolean): JSONObject { fun delete(auids: List<Long>, dry: Boolean): JSONObject {
synchronized(this) { synchronized(this) {
@ -237,6 +256,11 @@ class TimelineQuery(private val mCtx: MainActivity) {
return response 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 { private fun syncDb(startTime: Long): Int {
// Date modified is in seconds, not millis // Date modified is in seconds, not millis
val syncTime = Instant.now().toEpochMilli() / 1000; val syncTime = Instant.now().toEpochMilli() / 1000;
@ -287,6 +311,10 @@ class TimelineQuery(private val mCtx: MainActivity) {
return updates return updates
} }
/**
* Sync local database with system store.
* @return Number of updated files
*/
fun syncDeltaDb(): Int { fun syncDeltaDb(): Int {
// Get last sync time // Get last sync time
val syncTime = mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0) val syncTime = mCtx.getSharedPreferences(mCtx.getString(R.string.preferences_key), 0)
@ -294,6 +322,11 @@ class TimelineQuery(private val mCtx: MainActivity) {
return syncDb(syncTime) 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() { fun syncFullDb() {
// Flag all images for removal // Flag all images for removal
mPhotoDao.flagAll() mPhotoDao.flagAll()
@ -305,6 +338,10 @@ class TimelineQuery(private val mCtx: MainActivity) {
mPhotoDao.deleteFlagged() mPhotoDao.deleteFlagged()
} }
/**
* Insert item into local database.
* @param image SystemImage
*/
@SuppressLint("SimpleDateFormat") @SuppressLint("SimpleDateFormat")
private fun insertItemDb(image: SystemImage) { private fun insertItemDb(image: SystemImage) {
val fileId = image.fileId val fileId = image.fileId
@ -325,7 +362,10 @@ class TimelineQuery(private val mCtx: MainActivity) {
Log.v(TAG, "Inserted file to local DB: $fileId / $baseName") Log.v(TAG, "Inserted file to local DB: $fileId / $baseName")
} }
/** This is in timeline query because it calls the database service */ /**
* Active local folders response.
* This is in timeline query because it calls the database service.
*/
var localFolders: JSONArray var localFolders: JSONArray
get() { get() {
return mPhotoDao.getBuckets().map { return mPhotoDao.getBuckets().map {