Convert to kotlin

pull/653/merge
Varun Patil 2023-05-12 01:16:30 -07:00
parent 94e47a194c
commit 39f2af8dc3
12 changed files with 672 additions and 740 deletions

View File

@ -1,60 +0,0 @@
package gallery.memories;
import android.annotation.SuppressLint;
import android.os.Bundle;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.appcompat.app.AppCompatActivity;
import gallery.memories.databinding.ActivityMainBinding;
public class MainActivity extends AppCompatActivity {
public static final String TAG = "memories-native";
protected ActivityMainBinding binding;
protected NativeX mNativeX;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
mNativeX = new NativeX(this, binding.webview);
initializeWebView();
}
@SuppressLint("SetJavaScriptEnabled")
protected void initializeWebView() {
binding.webview.setWebViewClient(new WebViewClient() {
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
view.loadUrl(request.getUrl().toString());
return false;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
if (request.getUrl().getHost().equals("127.0.0.1")) {
return mNativeX.handleRequest(request);
}
return null;
}
});
WebSettings webSettings = binding.webview.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
webSettings.setAllowContentAccess(true);
webSettings.setDomStorageEnabled(true);
webSettings.setDatabaseEnabled(true);
webSettings.setUserAgentString("memories-native-android/0.0");
binding.webview.clearCache(true);
binding.webview.addJavascriptInterface(mNativeX, "nativex");
binding.webview.loadUrl("http://10.0.2.2:8035/index.php/apps/memories/");
}
}

View File

@ -0,0 +1,53 @@
package gallery.memories
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import gallery.memories.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var mNativeX: NativeX
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Initialize services
mNativeX = NativeX(this)
// Load JavaScript
initializeWebView()
}
@SuppressLint("SetJavaScriptEnabled")
protected fun initializeWebView() {
binding.webview.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
view.loadUrl(request.url.toString())
return false
}
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
return if (request.url.host == "127.0.0.1") {
mNativeX.handleRequest(request)
} else null
}
}
val webSettings = binding.webview.settings
webSettings.javaScriptEnabled = true
webSettings.javaScriptCanOpenWindowsAutomatically = true
webSettings.allowContentAccess = true
webSettings.domStorageEnabled = true
webSettings.databaseEnabled = true
webSettings.userAgentString = "memories-native-android/0.0"
binding.webview.clearCache(true)
binding.webview.addJavascriptInterface(mNativeX, "nativex")
binding.webview.loadUrl("http://10.0.2.2:8035/index.php/apps/memories/")
}
}

View File

@ -1,149 +0,0 @@
package gallery.memories;
import static android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS;
import android.graphics.Color;
import android.os.Build;
import android.util.Log;
import android.view.View;
import android.view.Window;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.collection.ArrayMap;
import java.io.ByteArrayInputStream;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import gallery.memories.service.DownloadService;
import gallery.memories.service.ImageService;
import gallery.memories.service.TimelineQuery;
public class NativeX {
public static final String TAG = "NativeX";
protected final AppCompatActivity mActivity;
protected final WebView mWebView;
protected final ImageService mImageService;
protected final TimelineQuery mQuery;
public static DownloadService mDlService;
public NativeX(AppCompatActivity activity, WebView webView) {
mActivity = activity;
mWebView = webView;
mImageService = new ImageService(activity);
mQuery = new TimelineQuery(activity);
mDlService = new DownloadService(activity);
}
public WebResourceResponse handleRequest(final WebResourceRequest request) {
final String path = request.getUrl().getPath();
WebResourceResponse response;
try {
if (request.getMethod().equals("GET")) {
response = routerGet(path);
} else if (request.getMethod().equals("OPTIONS")) {
response = new WebResourceResponse("text/plain", "UTF-8", new ByteArrayInputStream("".getBytes()));
} else {
throw new Exception("Method Not Allowed");
}
} catch (Exception e) {
Log.e(TAG, "handleRequest: ", e);
response = makeErrorResponse();
}
// Allow CORS from all origins
Map<String, String> headers = new ArrayMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Access-Control-Allow-Headers", "*");
response.setResponseHeaders(headers);
return response;
}
@JavascriptInterface
public boolean isNative() {
return true;
}
@JavascriptInterface
public void setThemeColor(final String color, final boolean isDark) {
Window window = mActivity.getWindow();
mActivity.setTheme(isDark
? android.R.style.Theme_Black
: android.R.style.Theme_Light);
window.setNavigationBarColor(Color.parseColor(color));
window.setStatusBarColor(Color.parseColor(color));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.getInsetsController().setSystemBarsAppearance(isDark ? 0 : APPEARANCE_LIGHT_STATUS_BARS, APPEARANCE_LIGHT_STATUS_BARS);
} else {
window.getDecorView().setSystemUiVisibility(isDark ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
}
@JavascriptInterface
public void downloadFromUrl(final String url, final String filename) {
mDlService.queue(url, filename);
}
protected WebResourceResponse routerGet(final String path) throws Exception {
String[] parts = path.split("/");
if (path.matches("^/image/preview/\\d+$")) {
return makeResponse(mImageService.getPreview(Long.parseLong(parts[3])), "image/jpeg");
} else if (path.matches("^/image/full/\\d+$")) {
return makeResponse(mImageService.getFull(Long.parseLong(parts[3])), "image/jpeg");
} else if (path.matches("^/api/image/info/\\d+$")) {
return makeResponse(mQuery.getImageInfo(Long.parseLong(parts[4])));
} else if (path.matches("^/api/image/delete/\\d+(,\\d+)*$")) {
return makeResponse(mQuery.delete(parseIds(parts[4])));
} else if (path.matches("^/api/days$")) {
return makeResponse(mQuery.getDays());
} else if (path.matches("/api/days/\\d+$")) {
return makeResponse(mQuery.getByDayId(Long.parseLong(parts[3])));
} else if (path.matches("/api/share/url/.+$")) {
return makeResponse(mDlService.shareUrl(URLDecoder.decode(parts[4], "UTF-8")));
} else if (path.matches("/api/share/blob/.+$")) {
return makeResponse(mDlService.shareBlobFromUrl(URLDecoder.decode(parts[4], "UTF-8")));
} else if (path.matches("/api/share/local/\\d+$")) {
return makeResponse(mDlService.shareLocal(Long.parseLong(parts[4])));
}
throw new Exception("Not Found");
}
protected WebResourceResponse makeResponse(byte[] bytes, String mimeType) {
if (bytes != null) {
return new WebResourceResponse(mimeType, "UTF-8", new ByteArrayInputStream(bytes));
}
return makeErrorResponse();
}
protected WebResourceResponse makeResponse(Object json) {
return makeResponse(json.toString().getBytes(), "application/json");
}
protected WebResourceResponse makeErrorResponse() {
WebResourceResponse response = new WebResourceResponse("application/json", "UTF-8", new ByteArrayInputStream("{}".getBytes()));
response.setStatusCodeAndReasonPhrase(500, "Internal Server Error");
return response;
}
protected static List<Long> parseIds(String ids) {
List<Long> result = new ArrayList<>();
for (String id : ids.split(",")) {
result.add(Long.parseLong(id));
}
return result;
}
}

View File

@ -0,0 +1,140 @@
package gallery.memories
import android.R
import android.graphics.Color
import android.os.Build
import android.util.Log
import android.view.View
import android.view.WindowInsetsController
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import androidx.appcompat.app.AppCompatActivity
import gallery.memories.service.DownloadService
import gallery.memories.service.ImageService
import gallery.memories.service.TimelineQuery
import java.io.ByteArrayInputStream
import java.net.URLDecoder
class NativeX(private val mActivity: AppCompatActivity) {
val TAG = "NativeX"
private val mImageService: ImageService = ImageService(mActivity)
private val mQuery: TimelineQuery = TimelineQuery(mActivity)
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 {
mDlService = DownloadService(mActivity)
}
companion object {
lateinit var mDlService: DownloadService
fun getDlService(): DownloadService {
return mDlService;
}
}
fun handleRequest(request: WebResourceRequest): WebResourceResponse {
val path = request.url.path
val response: WebResourceResponse = try {
if (request.method == "GET") {
routerGet(path)
} else if (request.method == "OPTIONS") {
WebResourceResponse("text/plain", "UTF-8", ByteArrayInputStream("".toByteArray()))
} else {
throw Exception("Method Not Allowed")
}
} catch (e: Exception) {
Log.e(TAG, "handleRequest: ", e)
makeErrorResponse()
}
// Allow CORS from all origins
response.responseHeaders = mapOf(
"Access-Control-Allow-Origin" to "*",
"Access-Control-Allow-Headers" to "*"
)
return response
}
@get:JavascriptInterface
val isNative: Boolean
get() = true
@JavascriptInterface
fun setThemeColor(color: String?, isDark: Boolean) {
val window = mActivity.window
mActivity.setTheme(if (isDark) R.style.Theme_Black else R.style.Theme_Light)
window.navigationBarColor = Color.parseColor(color)
window.statusBarColor = Color.parseColor(color)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.setSystemBarsAppearance(if (isDark) 0 else WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS)
} else {
window.decorView.systemUiVisibility = if (isDark) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}
}
@JavascriptInterface
fun downloadFromUrl(url: String?, filename: String?) {
mDlService.queue(url!!, filename!!)
}
@Throws(Exception::class)
private fun routerGet(path: String?): WebResourceResponse {
val parts = path!!.split("/").toTypedArray()
if (path.matches(API.IMAGE_PREVIEW)) {
return makeResponse(mImageService.getPreview(parts[3].toLong()), "image/jpeg")
} else if (path.matches(API.IMAGE_FULL)) {
return makeResponse(mImageService.getFull(parts[3].toLong()), "image/jpeg")
} else if (path.matches(API.IMAGE_INFO)) {
return makeResponse(mQuery.getImageInfo(parts[4].toLong()))
} else if (path.matches(API.IMAGE_DELETE)) {
return makeResponse(mQuery.delete(parseIds(parts[4])))
} else if (path.matches(API.DAYS)) {
return makeResponse(mQuery.getDays())
} else if (path.matches(API.DAY)) {
return makeResponse(mQuery.getByDayId(parts[3].toLong()))
} else if (path.matches(API.SHARE_URL)) {
return makeResponse(mDlService.shareUrl(URLDecoder.decode(parts[4], "UTF-8")))
} else if (path.matches(API.SHARE_BLOB)) {
return makeResponse(mDlService.shareBlobFromUrl(URLDecoder.decode(parts[4], "UTF-8")))
} else if (path.matches(API.SHARE_LOCAL)) {
return makeResponse(mDlService.shareLocal(parts[4].toLong()))
} else {
throw Exception("Not Found")
}
}
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<Long> {
return ids.split(",").map { it.toLong() }
}
}

View File

@ -1,30 +0,0 @@
package gallery.memories.service;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class DbService extends SQLiteOpenHelper {
public DbService(Context context) {
super(context, "memories", null, 24);
}
public void onCreate(SQLiteDatabase db) {
// Add table for images
db.execSQL("CREATE TABLE images ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "local_id INTEGER,"
+ "mtime INTEGER,"
+ "date_taken INTEGER,"
+ "dayid INTEGER,"
+ "exif_uid TEXT,"
+ "basename TEXT,"
+ "flag INTEGER"
+ ");");
}
public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) {
database.execSQL("DROP TABLE IF EXISTS images");
onCreate(database);
}
}

View File

@ -0,0 +1,27 @@
package gallery.memories.service
import android.content.Context
import android.database.sqlite.SQLiteOpenHelper
import android.database.sqlite.SQLiteDatabase
class DbService(context: Context) : SQLiteOpenHelper(context, "memories", null, 25) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_id INTEGER,
mtime INTEGER,
date_taken INTEGER,
dayid INTEGER,
exif_uid TEXT,
basename TEXT,
flag INTEGER
)
""")
}
override fun onUpgrade(database: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
database.execSQL("DROP TABLE IF EXISTS images")
onCreate(database)
}
}

View File

@ -7,6 +7,6 @@ import gallery.memories.NativeX
class DownloadBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
NativeX.mDlService?.runDownloadCallback(intent)
NativeX.getDlService().runDownloadCallback(intent)
}
}

View File

@ -13,7 +13,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.collection.ArrayMap
import java.util.concurrent.CountDownLatch
class DownloadService(val mActivity: AppCompatActivity) {
class DownloadService(private val mActivity: AppCompatActivity) {
private val mDownloads: MutableMap<Long, () -> Unit> = ArrayMap()
fun runDownloadCallback(intent: Intent) {

View File

@ -1,48 +0,0 @@
package gallery.memories.service;
import android.content.ContentUris;
import android.content.Context;
import android.graphics.Bitmap;
import android.provider.MediaStore;
import java.io.ByteArrayOutputStream;
public class ImageService {
Context mCtx;
public ImageService(Context context) {
mCtx = context;
}
public byte[] getPreview(final long id) throws Exception {
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(
mCtx.getContentResolver(), id, MediaStore.Images.Thumbnails.FULL_SCREEN_KIND, null);
if (bitmap == null) {
bitmap = MediaStore.Video.Thumbnails.getThumbnail(
mCtx.getContentResolver(), id, MediaStore.Video.Thumbnails.FULL_SCREEN_KIND, null);
}
if (bitmap == null) {
throw new Exception("Thumbnail not found");
}
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
return stream.toByteArray();
}
public byte[] getFull(final long id) throws Exception {
Bitmap bitmap = MediaStore.Images.Media.getBitmap(
mCtx.getContentResolver(), ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id));
if (bitmap == null) {
throw new Exception("Image not found");
}
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
return stream.toByteArray();
}
}

View File

@ -0,0 +1,35 @@
package gallery.memories.service
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.provider.MediaStore
import java.io.ByteArrayOutputStream
class ImageService(private val mCtx: Context) {
@Throws(Exception::class)
fun getPreview(id: Long): ByteArray {
val bitmap =
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()
}
@Throws(Exception::class)
fun getFull(id: Long): ByteArray {
val bitmap = MediaStore.Images.Media.getBitmap(
mCtx.contentResolver, ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id))
?: throw Exception("Image not found")
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
return stream.toByteArray()
}
}

View File

@ -1,451 +0,0 @@
package gallery.memories.service;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ContentUris;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.icu.text.SimpleDateFormat;
import android.icu.util.TimeZone;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.text.TextUtils;
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.appcompat.app.AppCompatActivity;
import androidx.collection.ArraySet;
import androidx.exifinterface.media.ExifInterface;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class TimelineQuery {
final static String TAG = "TimelineQuery";
AppCompatActivity mCtx;
SQLiteDatabase mDb;
boolean deleting = false;
ActivityResultLauncher<IntentSenderRequest> deleteIntentLauncher;
ActivityResult deleteResult;
public TimelineQuery(AppCompatActivity context) {
mCtx = context;
mDb = new DbService(context).getWritableDatabase();
deleteIntentLauncher = mCtx.registerForActivityResult(new ActivityResultContracts.StartIntentSenderForResult(), result -> {
synchronized (deleteIntentLauncher) {
deleteResult = result;
deleteIntentLauncher.notify();
}
});
fullSyncDb();
}
public JSONArray getByDayId(final long dayId) throws JSONException {
// Get list of images from DB
final Set<Long> imageIds = new ArraySet<>();
final Map<Long, Long> datesTaken = new HashMap<>();
try (Cursor cursor = mDb.rawQuery(
"SELECT local_id, date_taken FROM images WHERE dayid = ?",
new String[] { Long.toString(dayId) }
)) {
while (cursor.moveToNext()) {
final long localId = cursor.getLong(0);
final long dateTaken = cursor.getLong(1);
imageIds.add(localId);
datesTaken.put(localId, dateTaken);
}
}
// Nothing to do
if (imageIds.size() == 0) {
return new JSONArray();
}
// Filter for given day
String selection = MediaStore.Images.Media._ID
+ " IN (" + TextUtils.join(",", imageIds) + ")";
// Make list of files
ArrayList<JSONObject> files = new ArrayList<>();
// Add all images
try (Cursor cursor = mCtx.getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[] {
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.DATE_MODIFIED,
},
selection,
null,
null
)) {
while (cursor.moveToNext()) {
long fileId = cursor.getLong(0);
imageIds.remove(fileId);
files.add(new JSONObject()
.put(Fields.Photo.FILEID, fileId)
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.ETAG, Long.toString(cursor.getLong(6)))
.put(Fields.Photo.DATETAKEN, datesTaken.get(fileId))
.put(Fields.Photo.DAYID, dayId));
}
}
// Add all videos
try (Cursor cursor = mCtx.getContentResolver().query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
new String[] {
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.HEIGHT,
MediaStore.Video.Media.WIDTH,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.DURATION,
},
selection,
null,
null
)) {
while (cursor.moveToNext()) {
// Remove from list of ids
long fileId = cursor.getLong(0);
imageIds.remove(fileId);
files.add(new JSONObject()
.put(Fields.Photo.FILEID, fileId)
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.ETAG, Long.toString(cursor.getLong(6)))
.put(Fields.Photo.DATETAKEN, datesTaken.get(fileId))
.put(Fields.Photo.DAYID, dayId)
.put(Fields.Photo.ISVIDEO, 1)
.put(Fields.Photo.VIDEO_DURATION, cursor.getLong(7) / 1000));
}
}
// Remove files that were not found
if (imageIds.size() > 0) {
mDb.execSQL("DELETE FROM images WHERE local_id IN (" + TextUtils.join(",", imageIds) + ")");
}
// Return JSON string of files
return new JSONArray(files);
}
public JSONArray getDays() {
try (Cursor cursor = mDb.rawQuery(
"SELECT dayid, COUNT(local_id) FROM images GROUP BY dayid",
null
)) {
JSONArray days = new JSONArray();
while (cursor.moveToNext()) {
long id = cursor.getLong(0);
long count = cursor.getLong(1);
days.put(new JSONObject()
.put("dayid", id)
.put("count", count)
);
}
return days;
} catch (JSONException e) {
Log.e(TAG, "JSON error");
return new JSONArray();
}
}
public JSONObject getImageInfo(final long id) throws Exception {
try (Cursor cursor = mDb.rawQuery(
"SELECT local_id, date_taken, dayid FROM images WHERE local_id = ?",
new String[] { Long.toString(id) }
)) {
if (!cursor.moveToNext()) {
throw new Exception("Image not found");
}
final long localId = cursor.getLong(0);
final long dateTaken = cursor.getLong(1);
final long dayId = cursor.getLong(2);
try {
return _getImageInfoForCollection(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
localId, dateTaken, dayId
);
} catch (Exception e) {/* Ignore */}
try {
return _getImageInfoForCollection(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
localId, dateTaken, dayId
);
} catch (Exception e) {/* Ignore */}
}
throw new Exception("File not found in any collection");
}
public JSONObject _getImageInfoForCollection(
final Uri collection,
final long localId,
final long dateTaken,
final long dayId
) throws Exception {
String selection = MediaStore.Images.Media._ID + " = " + localId;
try (Cursor cursor = mCtx.getContentResolver().query(
collection,
new String[] {
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.DATA,
},
selection,
null,
null
)) {
if (!cursor.moveToNext()) {
throw new Exception("Image not found");
}
JSONObject obj = new JSONObject()
.put(Fields.Photo.FILEID, cursor.getLong(0))
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.DAYID, dayId)
.put(Fields.Photo.DATETAKEN, dateTaken)
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.PERMISSIONS, Fields.Perm.DELETE);
String uri = cursor.getString(6);
// Get EXIF data
try {
ExifInterface exif = new ExifInterface(uri);
JSONObject exifObj = new JSONObject();
exifObj.put("Aperture", exif.getAttribute(ExifInterface.TAG_APERTURE_VALUE));
exifObj.put("FocalLength", exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH));
exifObj.put("FNumber", exif.getAttribute(ExifInterface.TAG_F_NUMBER));
exifObj.put("ShutterSpeed", exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE));
exifObj.put("ExposureTime", exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME));
exifObj.put("ISO", exif.getAttribute(ExifInterface.TAG_ISO_SPEED));
exifObj.put("DateTimeOriginal", exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL));
exifObj.put("OffsetTimeOriginal", exif.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL));
exifObj.put("GPSLatitude", exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE));
exifObj.put("GPSLongitude", exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE));
exifObj.put("GPSAltitude", exif.getAttribute(ExifInterface.TAG_GPS_ALTITUDE));
exifObj.put("Make", exif.getAttribute(ExifInterface.TAG_MAKE));
exifObj.put("Model", exif.getAttribute(ExifInterface.TAG_MODEL));
exifObj.put("Orientation", exif.getAttribute(ExifInterface.TAG_ORIENTATION));
exifObj.put("Description", exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION));
obj.put(Fields.Photo.EXIF, exifObj);
} catch (IOException e) {
Log.e(TAG, "Error reading EXIF data for " + uri);
}
return obj;
}
}
public JSONObject delete(List<Long> ids) throws Exception {
synchronized (this) {
if (deleting) {
throw new Exception("Already deleting another set of images");
}
deleting = true;
}
try {
// List of URIs
List<Uri> uris = new ArrayList<>();
for (long id : ids) {
uris.add(ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id));
}
// Delete file with media store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
PendingIntent intent = MediaStore.createTrashRequest(mCtx.getContentResolver(), uris, true);
deleteIntentLauncher.launch(new IntentSenderRequest.Builder(intent.getIntentSender()).build());
// Wait for response
synchronized (deleteIntentLauncher) {
deleteIntentLauncher.wait();
}
// Throw if canceled or failed
if (deleteResult.getResultCode() != Activity.RESULT_OK) {
throw new Exception("Delete canceled or failed");
}
} else {
for (Uri uri : uris) {
mCtx.getContentResolver().delete(uri, null, null);
}
}
// Delete from images table
mDb.execSQL("DELETE FROM images WHERE local_id IN (" + TextUtils.join(",", ids) + ")");
return new JSONObject().put("message", "ok");
} finally {
synchronized (this) {
deleting = false;
}
}
}
protected void fullSyncDb() {
// Flag all images for removal
mDb.execSQL("UPDATE images SET flag = 1");
// Add all images
try (Cursor cursor = mCtx.getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[] {
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.DATA,
},
null,
null,
null
)) {
while (cursor.moveToNext()) {
insertItemDb(
cursor.getLong(0),
cursor.getString(1),
cursor.getLong(2),
cursor.getLong(3),
cursor.getString(4),
false
);
}
}
// Add all videos
try (Cursor cursor = mCtx.getContentResolver().query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
new String[] {
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_TAKEN,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.DATA,
},
null,
null,
null
)) {
while (cursor.moveToNext()) {
insertItemDb(
cursor.getLong(0),
cursor.getString(1),
cursor.getLong(2),
cursor.getLong(3),
cursor.getString(4),
true
);
}
}
// Clean up stale files
mDb.execSQL("DELETE FROM images WHERE flag = 1");
}
protected void insertItemDb(long id, String name, long dateTaken, long mtime, String uri, boolean isVideo) {
// Check if file with local_id and mtime already exists
try (Cursor c = mDb.rawQuery("SELECT id FROM images WHERE local_id = ?",
new String[]{Long.toString(id)})) {
if (c.getCount() > 0) {
// File already exists, remove flag
mDb.execSQL("UPDATE images SET flag = 0 WHERE local_id = ?", new Object[]{id});
Log.v(TAG, "File already exists: " + id + " / " + name);
return;
}
}
// Get EXIF date using ExifInterface if image
if (!isVideo) {
try {
ExifInterface exif = new ExifInterface(uri);
String exifDate = exif.getAttribute(ExifInterface.TAG_DATETIME);
if (exifDate == null) {
throw new IOException();
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
sdf.setTimeZone(android.icu.util.TimeZone.GMT_ZONE);
Date date = sdf.parse(exifDate);
if (date != null) {
dateTaken = date.getTime();
}
} catch (IOException e) {
Log.e(TAG, "Failed to read EXIF data: " + e.getMessage());
} catch (ParseException e) {
e.printStackTrace();
}
}
if (isVideo) {
// No way to get the actual local date, so just assume current timezone
dateTaken += TimeZone.getDefault().getOffset(dateTaken);
}
// This will use whatever is available
dateTaken /= 1000;
final long dayId = dateTaken / 86400;
// Delete file with same local_id and insert new one
mDb.beginTransaction();
mDb.execSQL("DELETE FROM images WHERE local_id = ?", new Object[] { id });
mDb.execSQL("INSERT OR IGNORE INTO images (local_id, mtime, basename, date_taken, dayid) VALUES (?, ?, ?, ?, ?)",
new Object[] { id, mtime, name, dateTaken, dayId });
mDb.setTransactionSuccessful();
mDb.endTransaction();
Log.v(TAG, "Inserted file to local DB: " + id + " / " + name + " / " + dayId);
}
}

View File

@ -0,0 +1,415 @@
package gallery.memories.service
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentUris
import android.database.sqlite.SQLiteDatabase
import android.icu.text.SimpleDateFormat
import android.icu.util.TimeZone
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.text.TextUtils
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.appcompat.app.AppCompatActivity
import androidx.collection.ArraySet
import androidx.exifinterface.media.ExifInterface
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.CountDownLatch
class TimelineQuery(private val mCtx: AppCompatActivity) {
private val mDb: SQLiteDatabase = DbService(mCtx).writableDatabase
private val TAG = "TimelineQuery"
// Photo deletion events
var deleting = false
var deleteIntentLauncher: ActivityResultLauncher<IntentSenderRequest>
var deleteCallback: ((ActivityResult?) -> Unit)? = null
init {
// Register intent launcher for callback
deleteIntentLauncher = mCtx.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult? ->
synchronized(this) {
deleteCallback?.let { it(result) }
}
}
// TODO: remove this in favor of a selective sync
fullSyncDb()
}
@Throws(JSONException::class)
fun getByDayId(dayId: Long): JSONArray {
// Get list of images from DB
val imageIds: MutableSet<Long?> = ArraySet()
val datesTaken: MutableMap<Long, Long> = HashMap()
val sql = "SELECT local_id, date_taken FROM images WHERE dayid = ?"
mDb.rawQuery(sql, arrayOf(dayId.toString())).use { cursor ->
while (cursor.moveToNext()) {
val localId = cursor.getLong(0)
datesTaken[localId] = cursor.getLong(1)
imageIds.add(localId)
}
}
// Nothing to do
if (imageIds.size == 0) return JSONArray()
// Filter for given day
val idColName = MediaStore.Images.Media._ID
val imageIdsSl = TextUtils.join(",", imageIds)
val selection = "$idColName IN ($imageIdsSl)"
// Make list of files
val files = ArrayList<JSONObject?>()
mCtx.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(
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.DATE_MODIFIED
),
selection,
null,
null
).use { cursor ->
while (cursor?.moveToNext() == true) {
val fileId = cursor.getLong(0)
imageIds.remove(fileId)
files.add(JSONObject()
.put(Fields.Photo.FILEID, fileId)
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.ETAG, java.lang.Long.toString(cursor.getLong(6)))
.put(Fields.Photo.DATETAKEN, datesTaken[fileId])
.put(Fields.Photo.DAYID, dayId))
}
}
mCtx.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.HEIGHT,
MediaStore.Video.Media.WIDTH,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.DURATION
),
selection,
null,
null
).use { cursor ->
while (cursor?.moveToNext() == true) {
// Remove from list of ids
val fileId = cursor.getLong(0)
imageIds.remove(fileId)
files.add(JSONObject()
.put(Fields.Photo.FILEID, fileId)
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.ETAG, java.lang.Long.toString(cursor.getLong(6)))
.put(Fields.Photo.DATETAKEN, datesTaken[fileId])
.put(Fields.Photo.DAYID, dayId)
.put(Fields.Photo.ISVIDEO, 1)
.put(Fields.Photo.VIDEO_DURATION, cursor.getLong(7) / 1000))
}
}
// Remove files that were not found
if (imageIds.size > 0) {
val delIds = TextUtils.join(",", imageIds)
mDb.execSQL("DELETE FROM images WHERE local_id IN ($delIds)")
}
// Return JSON string of files
return JSONArray(files)
}
@Throws(JSONException::class)
fun getDays(): JSONArray {
mDb.rawQuery(
"SELECT dayid, COUNT(local_id) FROM images GROUP BY dayid",
null
).use { cursor ->
val days = JSONArray()
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val count = cursor.getLong(1)
days.put(JSONObject()
.put("dayid", id)
.put("count", count)
)
}
return days
}
}
@Throws(Exception::class)
fun getImageInfo(id: Long): JSONObject {
val sql = "SELECT local_id, date_taken, dayid FROM images WHERE local_id = ?"
mDb.rawQuery(sql, arrayOf(id.toString())).use { cursor ->
if (!cursor.moveToNext()) {
throw Exception("Image not found")
}
val localId = cursor.getLong(0)
val dateTaken = cursor.getLong(1)
val dayId = cursor.getLong(2)
return getImageInfoForCollection(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
localId, dateTaken, dayId)
?: return getImageInfoForCollection(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
localId, dateTaken, dayId)
?: throw Exception("File not found in any collection")
}
}
private fun getImageInfoForCollection(
collection: Uri,
localId: Long,
dateTaken: Long,
dayId: Long
): JSONObject? {
val selection = MediaStore.Images.Media._ID + " = " + localId
mCtx.contentResolver.query(
collection,
arrayOf(
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.DATA
),
selection,
null,
null
).use { cursor ->
if (!cursor!!.moveToNext()) {
throw Exception("Image not found")
}
// Create basic info
val obj = JSONObject()
.put(Fields.Photo.FILEID, cursor.getLong(0))
.put(Fields.Photo.BASENAME, cursor.getString(1))
.put(Fields.Photo.MIMETYPE, cursor.getString(2))
.put(Fields.Photo.DAYID, dayId)
.put(Fields.Photo.DATETAKEN, dateTaken)
.put(Fields.Photo.HEIGHT, cursor.getLong(3))
.put(Fields.Photo.WIDTH, cursor.getLong(4))
.put(Fields.Photo.SIZE, cursor.getLong(5))
.put(Fields.Photo.PERMISSIONS, Fields.Perm.DELETE)
val uri = cursor.getString(6)
// Get EXIF data
try {
val exif = ExifInterface(uri)
obj.put(Fields.Photo.EXIF, JSONObject()
.put("Aperture", exif.getAttribute(ExifInterface.TAG_APERTURE_VALUE))
.put("FocalLength", exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH))
.put("FNumber", exif.getAttribute(ExifInterface.TAG_F_NUMBER))
.put("ShutterSpeed", exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE))
.put("ExposureTime", exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME))
.put("ISO", exif.getAttribute(ExifInterface.TAG_ISO_SPEED))
.put("DateTimeOriginal", exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL))
.put("OffsetTimeOriginal", exif.getAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL))
.put("GPSLatitude", exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE))
.put("GPSLongitude", exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE))
.put("GPSAltitude", exif.getAttribute(ExifInterface.TAG_GPS_ALTITUDE))
.put("Make", exif.getAttribute(ExifInterface.TAG_MAKE))
.put("Model", exif.getAttribute(ExifInterface.TAG_MODEL))
.put("Orientation", exif.getAttribute(ExifInterface.TAG_ORIENTATION))
.put("Description", exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION))
)
} catch (e: IOException) {
Log.e(TAG, "Error reading EXIF data for $uri")
}
return obj
}
}
@Throws(Exception::class)
fun delete(ids: List<Long>): JSONObject {
synchronized(this) {
if (deleting) {
throw Exception("Already deleting another set of images")
}
deleting = true
}
return try {
// List of URIs
val uris = ids.map {
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, it)
}
// 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 images table
val idsList = TextUtils.join(",", ids)
mDb.execSQL("DELETE FROM images WHERE local_id IN ($idsList)")
JSONObject().put("message", "ok")
} finally {
synchronized(this) { deleting = false }
}
}
protected fun fullSyncDb() {
// Flag all images for removal
mDb.execSQL("UPDATE images SET flag = 1")
mCtx.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.DATA
),
null,
null,
null
).use { cursor ->
while (cursor!!.moveToNext()) {
insertItemDb(
cursor.getLong(0),
cursor.getString(1),
cursor.getLong(2),
cursor.getLong(3),
cursor.getString(4),
false
)
}
}
mCtx.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_TAKEN,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.DATA
),
null,
null,
null
).use { cursor ->
while (cursor!!.moveToNext()) {
insertItemDb(
cursor.getLong(0),
cursor.getString(1),
cursor.getLong(2),
cursor.getLong(3),
cursor.getString(4),
true
)
}
}
// Clean up stale files
mDb.execSQL("DELETE FROM images WHERE flag = 1")
}
@SuppressLint("SimpleDateFormat")
private fun insertItemDb(
id: Long,
name: String,
dateTaken: Long,
mtime: Long,
uri: String,
isVideo: Boolean,
) {
var dateTaken = dateTaken
// Check if file with local_id and mtime already exists
mDb.rawQuery("SELECT id FROM images WHERE local_id = ?", arrayOf(id.toString())).use { c ->
if (c.count > 0) {
// File already exists, remove flag
mDb.execSQL("UPDATE images SET flag = 0 WHERE local_id = ?", arrayOf(id))
Log.v(TAG, "File already exists: $id / $name")
return
}
}
// Get EXIF date using ExifInterface if image
if (!isVideo) {
try {
val exif = ExifInterface(uri!!)
val exifDate = exif.getAttribute(ExifInterface.TAG_DATETIME)
?: throw IOException()
val sdf = SimpleDateFormat("yyyy:MM:dd HH:mm:ss")
sdf.timeZone = TimeZone.GMT_ZONE
val date = sdf.parse(exifDate)
if (date != null) {
dateTaken = date.time
}
} catch (e: Exception) {
Log.e(TAG, "Failed to read EXIF data: " + e.message)
}
}
// No way to get the actual local date, so just assume current timezone
if (isVideo) {
dateTaken += TimeZone.getDefault().getOffset(dateTaken).toLong()
}
// This will use whatever is available
dateTaken /= 1000
val dayId = dateTaken / 86400
// Delete file with same local_id and insert new one
mDb.beginTransaction()
mDb.execSQL("DELETE FROM images WHERE local_id = ?", arrayOf(id))
mDb.execSQL("INSERT OR IGNORE INTO images (local_id, mtime, basename, date_taken, dayid) VALUES (?, ?, ?, ?, ?)", arrayOf(id, mtime, name, dateTaken, dayId))
mDb.setTransactionSuccessful()
mDb.endTransaction()
Log.v(TAG, "Inserted file to local DB: $id / $name / $dayId")
}
}