From 4dee17b7de5c8f0dd8030c978f6b635fcf50c2ca Mon Sep 17 00:00:00 2001
From: pegasko <pegasko@pegasko.art>
Date: Wed, 12 Jun 2024 04:45:12 +0300
Subject: [PATCH] File export / import

---
 Yeeemp/app/src/main/AndroidManifest.xml       |   6 +-
 .../art/pegasko/yeeemp/impl/DBWrapper.java    |  22 ++-
 .../art/pegasko/yeeemp/impl/DataUtils.java    | 121 ++++++++++++++
 .../java/art/pegasko/yeeemp/impl/Init.java    |   4 +
 .../yeeemp/ui/activity/QueueListActivity.java | 151 ++++++++++++++++++
 .../art/pegasko/yeeemp/ui/activity/Utils.java |   6 +
 .../main/res/menu/queue_list_toolbar_menu.xml |  10 ++
 7 files changed, 315 insertions(+), 5 deletions(-)
 create mode 100644 Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DataUtils.java
 create mode 100644 Yeeemp/app/src/main/res/menu/queue_list_toolbar_menu.xml

diff --git a/Yeeemp/app/src/main/AndroidManifest.xml b/Yeeemp/app/src/main/AndroidManifest.xml
index 9b0c1a5..c7243d7 100644
--- a/Yeeemp/app/src/main/AndroidManifest.xml
+++ b/Yeeemp/app/src/main/AndroidManifest.xml
@@ -1,7 +1,11 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools">
 
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+
     <application
         android:allowBackup="true"
         android:dataExtractionRules="@xml/data_extraction_rules"
diff --git a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DBWrapper.java b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DBWrapper.java
index a162e31..44f4320 100644
--- a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DBWrapper.java
+++ b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DBWrapper.java
@@ -34,7 +34,7 @@ public class DBWrapper extends Wrapper {
     public static final boolean DEBUG = false;
 
     public DBWrapper(Context context) {
-        this.db = openDB(context);
+        this.db = openDB(context, DB_PATH);
         this.queueMaker = new QueueMakerImpl(this.db);
         this.eventMaker = new EventMakerImpl(this.db);
         this.tagMaker = new TagMakerImpl(this.db);
@@ -127,19 +127,33 @@ public class DBWrapper extends Wrapper {
         }
     }
 
+    /**
+     * Get internal database path
+     */
+    static File getDBPath(Context context) {
+        return new File(context.getFilesDir(), DB_PATH);
+    }
+
     /**
      * @return opened and initialized database
      */
-    private SQLiteDatabase openDB(Context context) {
+    private static SQLiteDatabase openDB(Context context, String dbPath) {
         if (DBWrapper.DEBUG) {
             try {
-                new File(context.getFilesDir(), DB_PATH).delete();
+                new File(context.getFilesDir(), dbPath).delete();
             } catch (Exception e) {
                 Log.wtf(TAG, e);
             }
         }
 
-        SQLiteDatabase db = SQLiteDatabase.openDatabase(new File(context.getFilesDir(), DB_PATH).getPath(), null, SQLiteDatabase.OPEN_READWRITE | SQLiteDatabase.CREATE_IF_NECESSARY);
+        File path = getDBPath(context);
+        SQLiteDatabase db;
+        if (!path.exists()) {
+            db = SQLiteDatabase.openDatabase(path.getPath(), null, SQLiteDatabase.OPEN_READWRITE | SQLiteDatabase.CREATE_IF_NECESSARY);
+        } else {
+            db = SQLiteDatabase.openDatabase(path.getPath(), null, SQLiteDatabase.OPEN_READWRITE);
+        }
+
         initDB(db);
         return db;
     }
diff --git a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DataUtils.java b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DataUtils.java
new file mode 100644
index 0000000..4d403e7
--- /dev/null
+++ b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/DataUtils.java
@@ -0,0 +1,121 @@
+/**
+ * Copyright 2024 pegasko
+ *
+ * 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.
+ */
+
+package art.pegasko.yeeemp.impl;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.FileUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+
+import art.pegasko.yeeemp.ui.activity.Utils;
+
+public class DataUtils {
+    public static final String DATE_FORMAT = "yyyy-MM-dd-HH-mm-ss";
+
+    public static String formatTs(long timestamp) {
+        SimpleDateFormat sdf = new SimpleDateFormat(DataUtils.DATE_FORMAT);
+        return sdf.format(timestamp);
+    }
+
+    private static void copyStream(InputStream fis, OutputStream fos) throws IOException {
+        byte[] buffer = new byte[1024];
+        int lengthRead;
+        while ((lengthRead = fis.read(buffer)) > 0) {
+            fos.write(buffer, 0, lengthRead);
+            fos.flush();
+        }
+    }
+
+    /* just copy internal db to external storage */
+    public static void exportDatabase(Context context, File directory) throws Exception {
+        directory.mkdirs();
+
+        File internalFile = DBWrapper.getDBPath(context);
+        File externalFile = new File(directory, "export_" + formatTs(System.currentTimeMillis()) + ".db");
+        FileInputStream fis = new FileInputStream(internalFile);
+        FileOutputStream fos = new FileOutputStream(externalFile);
+
+        copyStream(fis, fos);
+
+        fis.close();
+        fos.close();
+    }
+
+    /* just copy internal db to external storage */
+    public static void exportDatabase(Context context, Uri uri) throws Exception {
+        File internalFile = DBWrapper.getDBPath(context);
+        InputStream fis = new FileInputStream(internalFile);
+        OutputStream fos = context.getContentResolver().openOutputStream(uri);
+
+        copyStream(fis, fos);
+
+        fis.close();
+        fos.close();
+    }
+
+    /* just copy external db to internal storage */
+    public static void importDatabase(Context context, Uri uri) throws Exception {
+        File internalFile = DBWrapper.getDBPath(context);
+        InputStream fis = context.getContentResolver().openInputStream(uri);
+        OutputStream fos = new FileOutputStream(internalFile);
+
+        copyStream(fis, fos);
+
+        fis.close();
+        fos.close();
+    }
+
+    public static void backupDatabase(Context context) throws IOException {
+        File internalFile = DBWrapper.getDBPath(context);
+        File backupFile = new File(context.getFilesDir(), "backup.db");
+
+        FileInputStream fis = new FileInputStream(internalFile);
+        FileOutputStream fos = new FileOutputStream(backupFile);
+
+        copyStream(fis, fos);
+
+        fis.close();
+        fos.close();
+    }
+
+    public static void restoreDatabase(Context context) throws IOException {
+        File internalFile = DBWrapper.getDBPath(context);
+        File backupFile = new File(context.getFilesDir(), "backup.db");
+
+        FileInputStream fis = new FileInputStream(backupFile);
+        FileOutputStream fos = new FileOutputStream(internalFile);
+
+        copyStream(fis, fos);
+
+        fis.close();
+        fos.close();
+    }
+
+    public static void deleteBackupDatabase(Context context) throws IOException {
+        File backupFile = new File(context.getFilesDir(), "backup.db");
+        backupFile.delete();
+    }
+}
diff --git a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/Init.java b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/Init.java
index 87c7ea5..a44a35f 100644
--- a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/Init.java
+++ b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/impl/Init.java
@@ -25,4 +25,8 @@ public class Init {
         if (Wrapper.instance() == null)
             Wrapper.setInstance(new DBWrapper(context));
     }
+
+    public static void reinitDB(Context context) {
+        Wrapper.setInstance(new DBWrapper(context));
+    }
 }
\ No newline at end of file
diff --git a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/QueueListActivity.java b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/QueueListActivity.java
index d26418e..b0115c0 100644
--- a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/QueueListActivity.java
+++ b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/QueueListActivity.java
@@ -16,29 +16,53 @@
 
 package art.pegasko.yeeemp.ui.activity;
 
+import android.Manifest;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 
 import com.google.android.material.snackbar.Snackbar;
 
+import androidx.activity.result.ActivityResultLauncher;
 import androidx.appcompat.app.AppCompatActivity;
 
+import android.os.Environment;
 import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
 import android.view.View;
+import android.widget.Toast;
 
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.PermissionChecker;
 import androidx.navigation.ui.AppBarConfiguration;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 
+import java.io.File;
+import java.security.Permission;
+
 import art.pegasko.yeeemp.base.Queue;
 import art.pegasko.yeeemp.base.Wrapper;
 import art.pegasko.yeeemp.databinding.ActivityQueueListBinding;
 
 import art.pegasko.yeeemp.R;
+import art.pegasko.yeeemp.impl.DBWrapper;
+import art.pegasko.yeeemp.impl.DataUtils;
 import art.pegasko.yeeemp.impl.Init;
 
 public class QueueListActivity extends AppCompatActivity {
     public static final String TAG = QueueListActivity.class.getSimpleName();
 
+    private static final int REQUEST_CODE_CREATE_FILE = 37;
+    private static final int REQUEST_CODE_OPEN_FILE = 19;
+
     private AppBarConfiguration appBarConfiguration;
     private ActivityQueueListBinding binding;
     private RecyclerView queueList;
@@ -60,6 +84,26 @@ public class QueueListActivity extends AppCompatActivity {
         setContentView(binding.getRoot());
         setSupportActionBar(binding.toolbar);
 
+        /* Toolbar menu */
+        binding.toolbar.inflateMenu(R.menu.queue_list_toolbar_menu);
+        binding.toolbar.setOnMenuItemClickListener((MenuItem item) -> {
+            if (item.getItemId() == R.id.queue_list_toolbar_menu_export) {
+                Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+                intent.setType("*/*");
+                intent.putExtra(Intent.EXTRA_TITLE, "export_" + DataUtils.formatTs(System.currentTimeMillis()) + ".db");
+                startActivityForResult(intent, REQUEST_CODE_CREATE_FILE);
+
+                return true;
+            } else if (item.getItemId() == R.id.queue_list_toolbar_menu_import) {
+                Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+                intent.setType("*/*");
+                startActivityForResult(intent, REQUEST_CODE_OPEN_FILE);
+
+                return true;
+            }
+            return false;
+        });
+
         /* Queue list */
         queueListAdapter = new QueueRecyclerViewAdapter();
         queueList = findViewById(R.id.content_queue_list__list);
@@ -86,6 +130,113 @@ public class QueueListActivity extends AppCompatActivity {
         updateList();
     }
 
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+        super.onActivityResult(requestCode, resultCode, resultData);
+
+        if (requestCode == REQUEST_CODE_CREATE_FILE && resultCode == Activity.RESULT_OK) {
+            if (resultData != null) {
+                Uri uri = resultData.getData();
+                Log.i(TAG, "Exporting file to " + uri.toString());
+
+                try {
+                    DataUtils.exportDatabase(getApplicationContext(), uri);
+                    Toast.makeText(QueueListActivity.this, "Exported database", Toast.LENGTH_SHORT).show();
+                } catch (Exception e) {
+                    Log.wtf(TAG, e);
+
+                    new AlertDialog
+                        .Builder(QueueListActivity.this)
+                        .setTitle("Export failed")
+                        .setMessage(e.getMessage())
+                        .setCancelable(true)
+                        .setNegativeButton("OK", (DialogInterface dialog, int id) -> { dialog.cancel(); })
+                        .show();
+                }
+            }
+        } else if (requestCode == REQUEST_CODE_OPEN_FILE && resultCode == Activity.RESULT_OK) {
+            if (resultData != null) {
+                final Uri uri = resultData.getData();
+
+                new AlertDialog
+                    .Builder(QueueListActivity.this)
+                    .setTitle("Confirm action")
+                    .setMessage("Import file as database? This will overwrite local database with external file without checking")
+                    .setCancelable(true)
+                    .setPositiveButton("Yes", (DialogInterface dialog, int id) -> {
+                        Log.i(TAG, "Importing file from " + uri.toString());
+
+                        try {
+                            Log.i(TAG, "Backup before restore");
+                            DataUtils.backupDatabase(getApplicationContext());
+
+                            Log.i(TAG, "Importing database");
+                            DataUtils.importDatabase(getApplicationContext(), uri);
+                            Toast.makeText(QueueListActivity.this, "Imported database", Toast.LENGTH_SHORT).show();
+
+                            Log.i(TAG, "Reloading database");
+                            Init.reinitDB(getApplicationContext());
+                            updateList();
+
+                            Log.i(TAG, "Delete backup");
+                            DataUtils.deleteBackupDatabase(getApplicationContext());
+                        } catch (Exception e) {
+                            Log.e(TAG, "Import failed");
+                            Log.wtf(TAG, e);
+
+                            new AlertDialog
+                                .Builder(QueueListActivity.this)
+                                .setTitle("Import failed")
+                                .setMessage(e.getMessage())
+                                .setCancelable(true)
+                                .setNegativeButton("OK", (DialogInterface dialog2, int id2) -> { dialog2.cancel(); })
+                                .show();
+
+                            try {
+                                Log.i(TAG, "Trying to restore backup");
+                                DataUtils.restoreDatabase(getApplicationContext());
+
+                                Log.i(TAG, "Reloading database");
+                                Init.reinitDB(getApplicationContext());
+                                updateList();
+
+                                Log.i(TAG, "Delete backup");
+                                DataUtils.deleteBackupDatabase(getApplicationContext());
+
+                                new AlertDialog
+                                    .Builder(QueueListActivity.this)
+                                    .setTitle("Info")
+                                    .setMessage("Restored backup")
+                                    .setCancelable(true)
+                                    .setNegativeButton("OK", (DialogInterface dialog2, int id2) -> { dialog2.cancel(); })
+                                    .show();
+
+                            } catch (Exception e2) {
+                                Log.e(TAG, "Restore backup failed");
+                                Log.wtf(TAG, e2);
+
+                                new AlertDialog
+                                    .Builder(QueueListActivity.this)
+                                    .setTitle("Restore backup failed")
+                                    .setMessage(e2.getMessage())
+                                    .setCancelable(true)
+                                    .setNegativeButton("OK", (DialogInterface dialog2, int id2) -> { dialog2.cancel(); })
+                                    .show();
+                            }
+                        }
+                    })
+                    .setNegativeButton("Cancel", (DialogInterface dialog, int id) -> { dialog.cancel(); })
+                    .show();
+            }
+        }
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.queue_list_toolbar_menu, menu);
+        return true;
+    }
+
     @Override
     protected void onResume() {
         super.onResume();
diff --git a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/Utils.java b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/Utils.java
index 8d91f67..3663462 100644
--- a/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/Utils.java
+++ b/Yeeemp/app/src/main/java/art/pegasko/yeeemp/ui/activity/Utils.java
@@ -49,4 +49,10 @@ public class Utils {
 
         return finalItems;
     }
+
+    public static int positiveHashCode16(Object o) {
+        if (o == null)
+            return 0;
+        return Math.abs(o.hashCode()) & ((1 << 16) - 1);
+    }
 }
diff --git a/Yeeemp/app/src/main/res/menu/queue_list_toolbar_menu.xml b/Yeeemp/app/src/main/res/menu/queue_list_toolbar_menu.xml
new file mode 100644
index 0000000..261d8f1
--- /dev/null
+++ b/Yeeemp/app/src/main/res/menu/queue_list_toolbar_menu.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/queue_list_toolbar_menu_export"
+        android:title="Export" />
+
+    <item
+        android:id="@+id/queue_list_toolbar_menu_import"
+        android:title="Import" />
+</menu>
\ No newline at end of file