mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
SyncManager: First version
This commit is contained in:
@@ -34,6 +34,7 @@ import org.isoron.uhabits.core.ui.screens.habits.list.*;
|
|||||||
import org.isoron.uhabits.core.utils.*;
|
import org.isoron.uhabits.core.utils.*;
|
||||||
import org.isoron.uhabits.intents.*;
|
import org.isoron.uhabits.intents.*;
|
||||||
import org.isoron.uhabits.receivers.*;
|
import org.isoron.uhabits.receivers.*;
|
||||||
|
import org.isoron.uhabits.sync.*;
|
||||||
import org.isoron.uhabits.tasks.*;
|
import org.isoron.uhabits.tasks.*;
|
||||||
import org.isoron.uhabits.widgets.*;
|
import org.isoron.uhabits.widgets.*;
|
||||||
|
|
||||||
@@ -85,4 +86,6 @@ public interface HabitsApplicationComponent
|
|||||||
WidgetPreferences getWidgetPreferences();
|
WidgetPreferences getWidgetPreferences();
|
||||||
|
|
||||||
WidgetUpdater getWidgetUpdater();
|
WidgetUpdater getWidgetUpdater();
|
||||||
|
|
||||||
|
SyncManager getSyncManager();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.isoron.uhabits.core.tasks.*
|
|||||||
import org.isoron.uhabits.core.ui.ThemeSwitcher.*
|
import org.isoron.uhabits.core.ui.ThemeSwitcher.*
|
||||||
import org.isoron.uhabits.core.utils.*
|
import org.isoron.uhabits.core.utils.*
|
||||||
import org.isoron.uhabits.database.*
|
import org.isoron.uhabits.database.*
|
||||||
|
import org.isoron.uhabits.sync.*
|
||||||
|
|
||||||
class ListHabitsActivity : HabitsActivity() {
|
class ListHabitsActivity : HabitsActivity() {
|
||||||
|
|
||||||
@@ -38,10 +39,12 @@ class ListHabitsActivity : HabitsActivity() {
|
|||||||
lateinit var screen: ListHabitsScreen
|
lateinit var screen: ListHabitsScreen
|
||||||
lateinit var prefs: Preferences
|
lateinit var prefs: Preferences
|
||||||
lateinit var midnightTimer: MidnightTimer
|
lateinit var midnightTimer: MidnightTimer
|
||||||
|
lateinit var syncManager: SyncManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
prefs = appComponent.preferences
|
prefs = appComponent.preferences
|
||||||
|
syncManager = appComponent.syncManager
|
||||||
pureBlack = prefs.isPureBlackEnabled
|
pureBlack = prefs.isPureBlackEnabled
|
||||||
midnightTimer = appComponent.midnightTimer
|
midnightTimer = appComponent.midnightTimer
|
||||||
rootView = component.listHabitsRootView
|
rootView = component.listHabitsRootView
|
||||||
@@ -58,6 +61,7 @@ class ListHabitsActivity : HabitsActivity() {
|
|||||||
midnightTimer.onPause()
|
midnightTimer.onPause()
|
||||||
screen.onDettached()
|
screen.onDettached()
|
||||||
adapter.cancelRefresh()
|
adapter.cancelRefresh()
|
||||||
|
syncManager.onPause()
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,14 +70,13 @@ class ListHabitsActivity : HabitsActivity() {
|
|||||||
screen.onAttached()
|
screen.onAttached()
|
||||||
rootView.postInvalidate()
|
rootView.postInvalidate()
|
||||||
midnightTimer.onResume()
|
midnightTimer.onResume()
|
||||||
|
syncManager.onResume()
|
||||||
taskRunner.run {
|
taskRunner.run {
|
||||||
AutoBackup(this@ListHabitsActivity).run()
|
AutoBackup(this@ListHabitsActivity).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prefs.theme == THEME_DARK && prefs.isPureBlackEnabled != pureBlack) {
|
if (prefs.theme == THEME_DARK && prefs.isPureBlackEnabled != pureBlack) {
|
||||||
restartWithFade(ListHabitsActivity::class.java)
|
restartWithFade(ListHabitsActivity::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onResume()
|
super.onResume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,11 @@ class ListHabitsScreen
|
|||||||
commandRunner.addListener(this)
|
commandRunner.addListener(this)
|
||||||
if(activity.intent.action == "android.intent.action.VIEW") {
|
if(activity.intent.action == "android.intent.action.VIEW") {
|
||||||
val uri = activity.intent.data!!.toString()
|
val uri = activity.intent.data!!.toString()
|
||||||
val key = uri.replace(Regex("^.*sync/"), "")
|
val parts = uri.replace(Regex("^.*sync/"), "").split("#")
|
||||||
Log.i("ListHabitsScreen", key)
|
val syncKey = parts[0]
|
||||||
behavior.get().onSyncKeyOffer(key)
|
val encKey = parts[1]
|
||||||
|
Log.i("ListHabitsScreen", "sync: $syncKey enc: $encKey")
|
||||||
|
behavior.get().onSyncKeyOffer(syncKey, encKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +187,7 @@ class ListHabitsScreen
|
|||||||
COULD_NOT_GENERATE_BUG_REPORT -> R.string.bug_report_failed
|
COULD_NOT_GENERATE_BUG_REPORT -> R.string.bug_report_failed
|
||||||
FILE_NOT_RECOGNIZED -> R.string.file_not_recognized
|
FILE_NOT_RECOGNIZED -> R.string.file_not_recognized
|
||||||
SYNC_ENABLED -> R.string.sync_enabled
|
SYNC_ENABLED -> R.string.sync_enabled
|
||||||
|
SYNC_KEY_ALREADY_INSTALLED -> R.string.sync_key_already_installed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,12 +158,6 @@ public class SettingsFragment extends PreferenceFragmentCompat
|
|||||||
|
|
||||||
private void updateSyncPreferences()
|
private void updateSyncPreferences()
|
||||||
{
|
{
|
||||||
if(prefs.getSyncKey().isEmpty()) {
|
|
||||||
prefs.setSyncEnabled(false);
|
|
||||||
((CheckBoxPreference) findPreference("pref_sync_enabled")).setChecked(false);
|
|
||||||
}
|
|
||||||
findPreference("pref_sync_base_url").setSummary(prefs.getSyncBaseURL());
|
|
||||||
findPreference("pref_sync_key").setSummary(prefs.getSyncKey());
|
|
||||||
findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled());
|
findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,11 +184,15 @@ public class SettingsFragment extends PreferenceFragmentCompat
|
|||||||
}
|
}
|
||||||
if (key.equals("pref_sync_enabled"))
|
if (key.equals("pref_sync_enabled"))
|
||||||
{
|
{
|
||||||
Context context = getActivity();
|
|
||||||
if (prefs.isSyncEnabled())
|
if (prefs.isSyncEnabled())
|
||||||
{
|
{
|
||||||
Intent intent = new IntentFactory().startSyncActivity(context);
|
Context context = getActivity();
|
||||||
context.startActivity(intent);
|
context.startActivity(new IntentFactory().startSyncActivity(context));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
prefs.setEncryptionKey("");
|
||||||
|
prefs.setSyncKey("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackupManager.dataChanged("org.isoron.uhabits");
|
BackupManager.dataChanged("org.isoron.uhabits");
|
||||||
|
|||||||
@@ -32,15 +32,16 @@ import org.isoron.androidbase.activities.*
|
|||||||
import org.isoron.androidbase.utils.*
|
import org.isoron.androidbase.utils.*
|
||||||
import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome
|
import org.isoron.androidbase.utils.InterfaceUtils.getFontAwesome
|
||||||
import org.isoron.uhabits.*
|
import org.isoron.uhabits.*
|
||||||
import org.isoron.uhabits.activities.*
|
|
||||||
import org.isoron.uhabits.core.preferences.*
|
import org.isoron.uhabits.core.preferences.*
|
||||||
import org.isoron.uhabits.core.tasks.*
|
import org.isoron.uhabits.core.tasks.*
|
||||||
import org.isoron.uhabits.databinding.*
|
import org.isoron.uhabits.databinding.*
|
||||||
import org.isoron.uhabits.sync.*
|
import org.isoron.uhabits.sync.*
|
||||||
|
import org.isoron.uhabits.utils.*
|
||||||
|
|
||||||
|
|
||||||
class SyncActivity : BaseActivity() {
|
class SyncActivity : BaseActivity() {
|
||||||
|
|
||||||
|
private lateinit var syncManager: SyncManager
|
||||||
private lateinit var preferences: Preferences
|
private lateinit var preferences: Preferences
|
||||||
private lateinit var taskRunner: TaskRunner
|
private lateinit var taskRunner: TaskRunner
|
||||||
private lateinit var baseScreen: BaseScreen
|
private lateinit var baseScreen: BaseScreen
|
||||||
@@ -56,6 +57,7 @@ class SyncActivity : BaseActivity() {
|
|||||||
val component = (application as HabitsApplication).component
|
val component = (application as HabitsApplication).component
|
||||||
taskRunner = component.taskRunner
|
taskRunner = component.taskRunner
|
||||||
preferences = component.preferences
|
preferences = component.preferences
|
||||||
|
syncManager = component.syncManager
|
||||||
|
|
||||||
binding = ActivitySyncBinding.inflate(layoutInflater)
|
binding = ActivitySyncBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@@ -84,20 +86,26 @@ class SyncActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun displayCurrentKey() {
|
private fun displayCurrentKey() {
|
||||||
displayLink("https://loophabits.org/sync/${preferences.syncKey}")
|
displayLink("https://loophabits.org/sync/${preferences.syncKey}#${preferences.encryptionKey}")
|
||||||
displayPassword("6B2W9F5X")
|
displayPassword("6B2W9F5X")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun register() {
|
private fun register() {
|
||||||
displayLoading()
|
displayLoading()
|
||||||
taskRunner.execute(object : Task {
|
taskRunner.execute(object : Task {
|
||||||
private var key = ""
|
private lateinit var encKey: String
|
||||||
|
private lateinit var syncKey: String
|
||||||
private var error = false
|
private var error = false
|
||||||
override fun doInBackground() {
|
override fun doInBackground() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val server = RemoteSyncServer(baseURL = preferences.syncBaseURL)
|
val server = RemoteSyncServer(baseURL = preferences.syncBaseURL)
|
||||||
try {
|
try {
|
||||||
key = server.register()
|
syncKey = server.register()
|
||||||
|
encKey = generateEncryptionKey()
|
||||||
|
preferences.isSyncEnabled = true
|
||||||
|
preferences.encryptionKey = encKey
|
||||||
|
preferences.syncKey = syncKey;
|
||||||
|
syncManager.sync()
|
||||||
} catch (e: ServiceUnavailable) {
|
} catch (e: ServiceUnavailable) {
|
||||||
error = true
|
error = true
|
||||||
}
|
}
|
||||||
@@ -109,7 +117,6 @@ class SyncActivity : BaseActivity() {
|
|||||||
displayError()
|
displayError()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preferences.syncKey = key;
|
|
||||||
displayCurrentKey()
|
displayCurrentKey()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class RemoteSyncServer(
|
|||||||
|
|
||||||
override suspend fun put(key: String, newData: SyncData) {
|
override suspend fun put(key: String, newData: SyncData) {
|
||||||
try {
|
try {
|
||||||
val response: String = httpClient.put("$baseURL/$key") {
|
val response: String = httpClient.put("$baseURL/db/$key") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
body = newData
|
body = newData
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ class RemoteSyncServer(
|
|||||||
|
|
||||||
override suspend fun getData(key: String): SyncData {
|
override suspend fun getData(key: String): SyncData {
|
||||||
try {
|
try {
|
||||||
return httpClient.get("$baseURL/$key")
|
return httpClient.get("$baseURL/db/$key")
|
||||||
} catch (e: ServerResponseException) {
|
} catch (e: ServerResponseException) {
|
||||||
throw ServiceUnavailable()
|
throw ServiceUnavailable()
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
@@ -69,7 +69,7 @@ class RemoteSyncServer(
|
|||||||
|
|
||||||
override suspend fun getDataVersion(key: String): Long {
|
override suspend fun getDataVersion(key: String): Long {
|
||||||
try {
|
try {
|
||||||
val response: GetDataVersionResponse = httpClient.get("$baseURL/$key/version")
|
val response: GetDataVersionResponse = httpClient.get("$baseURL/db/$key/version")
|
||||||
return response.version
|
return response.version
|
||||||
} catch(e: ServerResponseException) {
|
} catch(e: ServerResponseException) {
|
||||||
throw ServiceUnavailable()
|
throw ServiceUnavailable()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
|
||||||
|
*
|
||||||
|
* This file is part of Loop Habit Tracker.
|
||||||
|
*
|
||||||
|
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*
|
||||||
|
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along
|
||||||
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.isoron.uhabits.sync
|
||||||
|
|
||||||
|
import android.content.*
|
||||||
|
import android.util.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import org.isoron.androidbase.*
|
||||||
|
import org.isoron.uhabits.core.*
|
||||||
|
import org.isoron.uhabits.core.preferences.*
|
||||||
|
import org.isoron.uhabits.core.tasks.*
|
||||||
|
import org.isoron.uhabits.tasks.*
|
||||||
|
import org.isoron.uhabits.utils.*
|
||||||
|
import java.io.*
|
||||||
|
import javax.inject.*
|
||||||
|
|
||||||
|
@AppScope
|
||||||
|
class SyncManager @Inject constructor(
|
||||||
|
val preferences: Preferences,
|
||||||
|
val taskRunner: TaskRunner,
|
||||||
|
val importDataTaskFactory: ImportDataTaskFactory,
|
||||||
|
@AppContext val context: Context
|
||||||
|
) : Preferences.Listener {
|
||||||
|
|
||||||
|
private val server = RemoteSyncServer()
|
||||||
|
private val tmpFile = File.createTempFile("import", "", context.externalCacheDir)
|
||||||
|
private var currVersion = 0L
|
||||||
|
|
||||||
|
init {
|
||||||
|
preferences.addListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun sync() {
|
||||||
|
if(!preferences.isSyncEnabled) {
|
||||||
|
Log.i("SyncManager", "Device sync is disabled. Skipping sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskRunner.execute {
|
||||||
|
runBlocking {
|
||||||
|
try {
|
||||||
|
Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})")
|
||||||
|
fetchAndMerge()
|
||||||
|
upload()
|
||||||
|
Log.i("SyncManager", "Sync finished")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e)
|
||||||
|
preferences.isSyncEnabled = false
|
||||||
|
preferences.syncKey = ""
|
||||||
|
preferences.encryptionKey = ""
|
||||||
|
}
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun upload() {
|
||||||
|
Log.i("SyncManager", "Encrypting database...")
|
||||||
|
val db = DatabaseUtils.getDatabaseFile(context)
|
||||||
|
val encryptedDB = db.encryptToString(preferences.encryptionKey)
|
||||||
|
Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)")
|
||||||
|
server.put(preferences.syncKey, SyncData(currVersion, encryptedDB))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAndMerge() {
|
||||||
|
Log.i("SyncManager", "Fetching database from server...")
|
||||||
|
val data = server.getData(preferences.syncKey)
|
||||||
|
Log.i("SyncManager", "Fetched database (version ${data.version}, ${data.content.length / 1024} KB)")
|
||||||
|
if (data.version <= currVersion) {
|
||||||
|
Log.i("SyncManager", "Local version is up-to-date. Skipping merge.")
|
||||||
|
} else {
|
||||||
|
Log.i("SyncManager", "Decrypting and merging with local changes...")
|
||||||
|
data.content.decryptToFile(preferences.encryptionKey, tmpFile)
|
||||||
|
taskRunner.execute(importDataTaskFactory.create(tmpFile) { tmpFile.delete() })
|
||||||
|
}
|
||||||
|
currVersion = data.version + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume() = sync()
|
||||||
|
fun onPause() = sync()
|
||||||
|
override fun onSyncEnabled() = sync()
|
||||||
|
}
|
||||||
@@ -214,4 +214,5 @@
|
|||||||
<string name="copied_to_the_clipboard">Copied to the clipboard</string>
|
<string name="copied_to_the_clipboard">Copied to the clipboard</string>
|
||||||
<string name="sync_confirm"><![CDATA[Are you trying to enable device sync?\n\nThis feature allows you to sync your data across multiple devices. When enabled, an encrypted copy of your data will be uploaded to our servers.]]></string>
|
<string name="sync_confirm"><![CDATA[Are you trying to enable device sync?\n\nThis feature allows you to sync your data across multiple devices. When enabled, an encrypted copy of your data will be uploaded to our servers.]]></string>
|
||||||
<string name="sync_enabled">Device sync enabled</string>
|
<string name="sync_enabled">Device sync enabled</string>
|
||||||
|
<string name="sync_key_already_installed">Sync key already installed</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -235,6 +235,13 @@
|
|||||||
app:iconSpaceReserved="false"
|
app:iconSpaceReserved="false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditTextPreference
|
||||||
|
android:defaultValue=""
|
||||||
|
android:key="pref_encryption_key"
|
||||||
|
android:title="Encryption key"
|
||||||
|
app:iconSpaceReserved="false"
|
||||||
|
/>
|
||||||
|
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
|
|
||||||
</PreferenceScreen>
|
</PreferenceScreen>
|
||||||
@@ -324,6 +324,16 @@ public class Preferences
|
|||||||
storage.putString("pref_sync_key", key);
|
storage.putString("pref_sync_key", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getEncryptionKey()
|
||||||
|
{
|
||||||
|
return storage.getString("pref_encryption_key", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncryptionKey(String key)
|
||||||
|
{
|
||||||
|
storage.putString("pref_encryption_key", key);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isSyncEnabled()
|
public boolean isSyncEnabled()
|
||||||
{
|
{
|
||||||
return storage.getBoolean("pref_sync_enabled", false);
|
return storage.getBoolean("pref_sync_enabled", false);
|
||||||
@@ -332,6 +342,7 @@ public class Preferences
|
|||||||
public void setSyncEnabled(boolean enabled)
|
public void setSyncEnabled(boolean enabled)
|
||||||
{
|
{
|
||||||
storage.putBoolean("pref_sync_enabled", enabled);
|
storage.putBoolean("pref_sync_enabled", enabled);
|
||||||
|
if(enabled) for (Listener l : listeners) l.onSyncEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -357,6 +368,10 @@ public class Preferences
|
|||||||
default void onNotificationsChanged()
|
default void onNotificationsChanged()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default void onSyncEnabled()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface Storage
|
public interface Storage
|
||||||
|
|||||||
@@ -158,10 +158,15 @@ public class ListHabitsBehavior
|
|||||||
habit.getId());
|
habit.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onSyncKeyOffer(@NotNull String key)
|
public void onSyncKeyOffer(@NotNull String syncKey, @NotNull String encryptionKey)
|
||||||
{
|
{
|
||||||
|
if(prefs.getSyncKey().equals(syncKey)) {
|
||||||
|
screen.showMessage(Message.SYNC_KEY_ALREADY_INSTALLED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
screen.showConfirmInstallSyncKey(() -> {
|
screen.showConfirmInstallSyncKey(() -> {
|
||||||
prefs.setSyncKey(key);
|
prefs.setSyncKey(syncKey);
|
||||||
|
prefs.setEncryptionKey(encryptionKey);
|
||||||
prefs.setSyncEnabled(true);
|
prefs.setSyncEnabled(true);
|
||||||
screen.showMessage(Message.SYNC_ENABLED);
|
screen.showMessage(Message.SYNC_ENABLED);
|
||||||
});
|
});
|
||||||
@@ -170,7 +175,7 @@ public class ListHabitsBehavior
|
|||||||
public enum Message
|
public enum Message
|
||||||
{
|
{
|
||||||
COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, IMPORT_FAILED, DATABASE_REPAIRED,
|
COULD_NOT_EXPORT, IMPORT_SUCCESSFUL, IMPORT_FAILED, DATABASE_REPAIRED,
|
||||||
COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED
|
COULD_NOT_GENERATE_BUG_REPORT, FILE_NOT_RECOGNIZED, SYNC_ENABLED, SYNC_KEY_ALREADY_INSTALLED
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface BugReporter
|
public interface BugReporter
|
||||||
|
|||||||
Reference in New Issue
Block a user