From 04954b96f7d5110602bc54eb2cf5f54f572ad584 Mon Sep 17 00:00:00 2001 From: Billy Brawner Date: Sun, 18 Aug 2019 18:56:21 -0700 Subject: [PATCH] WIP: Migrate to ViewModel architecture Signed-off-by: Billy Brawner --- app/build.gradle | 5 +- .../wbrawner/simplemarkdown/AppComponent.kt | 28 ---- .../com/wbrawner/simplemarkdown/AppModule.kt | 24 ---- .../simplemarkdown/MarkdownApplication.kt | 22 ++- .../simplemarkdown/model/MarkdownFile.kt | 21 --- .../presentation/MarkdownPresenter.kt | 39 ------ .../presentation/MarkdownPresenterImpl.kt | 127 ------------------ .../simplemarkdown/utility/Extensions.kt | 51 ++++++- .../view/activity/MainActivity.kt | 110 +++++++++------ .../view/activity/SplashActivity.kt | 106 +++++++-------- .../view/fragment/EditFragment.kt | 86 ++++-------- .../view/fragment/PreviewFragment.kt | 94 ++++++------- .../view/fragment/SettingsFragment.kt | 94 ++++++++----- .../viewmodel/MarkdownViewModel.kt | 106 +++++++++++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_general.xml | 2 +- 16 files changed, 427 insertions(+), 489 deletions(-) delete mode 100644 app/src/main/java/com/wbrawner/simplemarkdown/AppComponent.kt delete mode 100644 app/src/main/java/com/wbrawner/simplemarkdown/AppModule.kt delete mode 100644 app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenter.kt delete mode 100644 app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenterImpl.kt create mode 100644 app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 02aa630..1a535ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,7 +101,10 @@ dependencies { def coroutines_version = "1.3.0-RC2" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' + + def lifecycle_version = "2.0.0" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" implementation 'eu.crydee:syllable-counter:4.0.2' } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/AppComponent.kt b/app/src/main/java/com/wbrawner/simplemarkdown/AppComponent.kt deleted file mode 100644 index c6e2c46..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/AppComponent.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.wbrawner.simplemarkdown - -import android.content.Context -import com.wbrawner.simplemarkdown.view.activity.MainActivity -import com.wbrawner.simplemarkdown.view.activity.SplashActivity -import com.wbrawner.simplemarkdown.view.fragment.EditFragment -import com.wbrawner.simplemarkdown.view.fragment.PreviewFragment -import dagger.BindsInstance -import dagger.Component -import javax.inject.Singleton - -@Singleton -@Component(modules = [AppModule::class]) -interface AppComponent { - fun inject(application: MarkdownApplication) - fun inject(activity: MainActivity) - fun inject(activity: SplashActivity) - fun inject(fragment: EditFragment) - fun inject(fragment: PreviewFragment) - - @Component.Builder - abstract class Builder { - @BindsInstance - internal abstract fun context(context: Context): Builder - - internal abstract fun build(): AppComponent - } -} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/AppModule.kt b/app/src/main/java/com/wbrawner/simplemarkdown/AppModule.kt deleted file mode 100644 index 58c73fa..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/AppModule.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.wbrawner.simplemarkdown - -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenterImpl -import com.wbrawner.simplemarkdown.utility.CrashlyticsErrorHandler -import com.wbrawner.simplemarkdown.utility.ErrorHandler -import dagger.Module -import dagger.Provides -import javax.inject.Singleton - -@Module -class AppModule { - @Provides - @Singleton - fun provideMarkdownPresenter(errorHandler: ErrorHandler): MarkdownPresenter { - return MarkdownPresenterImpl(errorHandler) - } - - @Provides - @Singleton - internal fun provideErrorHandler(): ErrorHandler { - return CrashlyticsErrorHandler() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt index cbe0516..4081488 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/MarkdownApplication.kt @@ -1,16 +1,26 @@ package com.wbrawner.simplemarkdown import android.app.Application +import android.os.StrictMode +import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModelFactory class MarkdownApplication : Application() { - - lateinit var component: AppComponent - private set + val viewModelFactory: MarkdownViewModelFactory by lazy { + MarkdownViewModelFactory() + } override fun onCreate() { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyDeath() + .build()) +// StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() +// .detectAll() +// .penaltyLog() +// .penaltyDeath() +// .build()) + } super.onCreate() - component = DaggerAppComponent.builder() - .context(this) - .build() } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/model/MarkdownFile.kt b/app/src/main/java/com/wbrawner/simplemarkdown/model/MarkdownFile.kt index 108ea11..547adac 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/model/MarkdownFile.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/model/MarkdownFile.kt @@ -10,25 +10,4 @@ import java.io.Reader */ class MarkdownFile(var name: String = "Untitled.md", var content: String = "") { - fun load(name: String, inputStream: InputStream): Boolean { - this.name = name - return try { - this.content = inputStream.reader().use(Reader::readText) - true - } catch (e: Throwable) { - false - } - } - - fun save(name: String, outputStream: OutputStream): Boolean { - this.name = name - return try { - outputStream.writer().use { - it.write(this.content) - } - true - } catch (e: Throwable) { - false - } - } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenter.kt b/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenter.kt deleted file mode 100644 index 86d606b..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.wbrawner.simplemarkdown.presentation - -import android.content.Context -import android.net.Uri - -import java.io.InputStream -import java.io.OutputStream - -interface MarkdownPresenter { - var fileName: String - var markdown: String - var editView: MarkdownEditView? - var previewView: MarkdownPreviewView? - suspend fun loadFromUri(context: Context, fileUri: Uri): String? - suspend fun loadMarkdown( - fileName: String, - `in`: InputStream, - replaceCurrentFile: Boolean = true - ): String? - fun newFile(newName: String) - suspend fun saveMarkdown(name: String, outputStream: OutputStream): Boolean - fun onMarkdownEdited(markdown: String? = null) - fun generateHTML(markdown: String = ""): String -} - -interface MarkdownEditView { - var markdown: String - fun setTitle(title: String) - - fun onFileSaved(success: Boolean) - - fun onFileLoaded(success: Boolean) -} - -interface MarkdownPreviewView { - fun updatePreview(html: String) -} - - diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenterImpl.kt b/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenterImpl.kt deleted file mode 100644 index 111b7de..0000000 --- a/app/src/main/java/com/wbrawner/simplemarkdown/presentation/MarkdownPresenterImpl.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.wbrawner.simplemarkdown.presentation - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import com.commonsware.cwac.anddown.AndDown -import com.wbrawner.simplemarkdown.model.MarkdownFile -import com.wbrawner.simplemarkdown.utility.ErrorHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class MarkdownPresenterImpl @Inject constructor(private val errorHandler: ErrorHandler) : MarkdownPresenter { - @Volatile - private var file: MarkdownFile = MarkdownFile() - @Volatile - override var editView: MarkdownEditView? = null - set(value) { - field = value - onMarkdownEdited(null) - } - @Volatile - override var previewView: MarkdownPreviewView? = null - - override var fileName: String - get() = file.name - set(name) { - file.name = name - } - - override var markdown: String - get() = file.content - set(markdown) { - file.content = markdown - } - - override suspend fun loadMarkdown( - fileName: String, - `in`: InputStream, - replaceCurrentFile: Boolean - ): String? { - val tmpFile = MarkdownFile() - withContext(Dispatchers.IO) { - if (!tmpFile.load(fileName, `in`)) { - throw RuntimeException("Failed to load markdown") - } - } - if (replaceCurrentFile) { - this.file = tmpFile - editView?.let { - it.onFileLoaded(true) - it.setTitle(fileName) - it.markdown = file.content - onMarkdownEdited(file.content) - } - } - return generateHTML(tmpFile.content) - } - - override fun newFile(newName: String) { - editView?.let { - file.content = it.markdown - it.setTitle(newName) - it.markdown = "" - } - file = MarkdownFile(newName, "") - } - - override suspend fun saveMarkdown(name: String, outputStream: OutputStream): Boolean { - val result = withContext(Dispatchers.IO) { - file.save(name, outputStream) - } - editView?.let { - it.setTitle(file.name) - it.onFileSaved(result) - } - return result - } - - override fun onMarkdownEdited(markdown: String?) { - this.markdown = markdown ?: file.content - previewView?.updatePreview(generateHTML(this.markdown)) - } - - override fun generateHTML(markdown: String): String { - return AndDown().markdownToHtml(markdown, HOEDOWN_FLAGS, 0) - } - - override suspend fun loadFromUri(context: Context, fileUri: Uri): String? { - return try { - var fileName: String? = null - if ("content" == fileUri.scheme) { - context.contentResolver - .query( - fileUri, - null, - null, - null, - null - ) - ?.use { - val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - it.moveToFirst() - fileName = it.getString(nameIndex) - } - } else if ("file" == fileUri.scheme) { - fileName = fileUri.lastPathSegment - } - val inputStream = context.contentResolver.openInputStream(fileUri) ?: return null - loadMarkdown(fileName ?: "Untitled.md", inputStream, true) - } catch (e: Exception) { - errorHandler.reportException(e) - editView?.onFileLoaded(false) - null - } - } - - companion object { - const val HOEDOWN_FLAGS = AndDown.HOEDOWN_EXT_STRIKETHROUGH or AndDown.HOEDOWN_EXT_TABLES or - AndDown.HOEDOWN_EXT_UNDERLINE or AndDown.HOEDOWN_EXT_SUPERSCRIPT or - AndDown.HOEDOWN_EXT_FENCED_CODE - } -} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt b/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt index 0762ee4..d87cae0 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/utility/Extensions.kt @@ -1,8 +1,15 @@ package com.wbrawner.simplemarkdown.utility import android.content.Context +import android.content.res.AssetManager +import android.net.Uri +import android.provider.OpenableColumns import android.view.View import android.view.inputmethod.InputMethodManager +import com.commonsware.cwac.anddown.AndDown +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.Reader fun View.showKeyboard() = (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) @@ -10,4 +17,46 @@ fun View.showKeyboard() = fun View.hideKeyboard() = (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) - .hideSoftInputFromWindow(windowToken, 0) \ No newline at end of file + .hideSoftInputFromWindow(windowToken, 0) + +suspend fun AssetManager.readAssetToString(asset: String): String? { + return withContext(Dispatchers.IO) { + open(asset).reader().use(Reader::readText) + } +} + +const val HOEDOWN_FLAGS = AndDown.HOEDOWN_EXT_STRIKETHROUGH or AndDown.HOEDOWN_EXT_TABLES or + AndDown.HOEDOWN_EXT_UNDERLINE or AndDown.HOEDOWN_EXT_SUPERSCRIPT or + AndDown.HOEDOWN_EXT_FENCED_CODE + +suspend fun String.toHtml(): String { + return withContext(Dispatchers.IO) { + AndDown().markdownToHtml(this@toHtml, HOEDOWN_FLAGS, 0) + } +} + +suspend fun Uri.getName(context: Context): String { + var fileName: String? = null + try { + if ("content" == scheme) { + withContext(Dispatchers.IO) { + context.contentResolver.query( + this@getName, + null, + null, + null, + null + )?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + it.moveToFirst() + fileName = it.getString(nameIndex) + } + } + } else if ("file" == scheme) { + fileName = lastPathSegment + } + } catch (ignored: Exception) { + ignored.printStackTrace() + } + return fileName ?: "Untitled.md" +} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt index 753e44d..57ffe16 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/MainActivity.kt @@ -5,10 +5,10 @@ import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration +import android.net.Uri import android.os.Build import android.os.Bundle import android.preference.PreferenceManager -import android.provider.OpenableColumns import android.view.Menu import android.view.MenuItem import android.view.View @@ -18,11 +18,16 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders import com.wbrawner.simplemarkdown.MarkdownApplication import com.wbrawner.simplemarkdown.R -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter import com.wbrawner.simplemarkdown.utility.ErrorHandler +import com.wbrawner.simplemarkdown.utility.getName +import com.wbrawner.simplemarkdown.utility.readAssetToString +import com.wbrawner.simplemarkdown.utility.toHtml import com.wbrawner.simplemarkdown.view.adapter.EditPagerAdapter +import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import kotlinx.android.synthetic.main.activity_main.* import kotlinx.coroutines.* import java.io.File @@ -32,12 +37,11 @@ import kotlin.coroutines.CoroutineContext class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsResultCallback, CoroutineScope { - @Inject - lateinit var presenter: MarkdownPresenter @Inject lateinit var errorHandler: ErrorHandler private var shouldAutoSave = true override val coroutineContext: CoroutineContext = Dispatchers.Main + private lateinit var viewModel: MarkdownViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -50,7 +54,10 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN ) } - (application as MarkdownApplication).component.inject(this) + viewModel = ViewModelProviders.of( + this, + (application as MarkdownApplication).viewModelFactory + ).get(MarkdownViewModel::class.java) val adapter = EditPagerAdapter(supportFragmentManager, this@MainActivity) pager.adapter = adapter pager.addOnPageChangeListener(adapter) @@ -60,16 +67,38 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { tabLayout!!.visibility = View.GONE } + @Suppress("CAST_NEVER_SUCCEEDS") + viewModel.fileName.observe(this, Observer { + title = it + }) } override fun onUserLeaveHint() { super.onUserLeaveHint() - val isAutoSaveEnabled = PreferenceManager.getDefaultSharedPreferences(this) - .getBoolean(KEY_AUTOSAVE, true) - if (shouldAutoSave && presenter.markdown.isNotEmpty() && isAutoSaveEnabled) { + launch { + withContext(Dispatchers.IO) { + val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this@MainActivity) + val isAutoSaveEnabled = sharedPrefs.getBoolean(KEY_AUTOSAVE, true) + if (!shouldAutoSave || !isAutoSaveEnabled) { + return@withContext + } - launch { - presenter.saveMarkdown("autosave.md", File(filesDir, "autosave.md").outputStream()) + val uri = if (viewModel.save(this@MainActivity)) { + viewModel.uri.value + } else { + // The user has left the app, with autosave enabled, and we don't already have a + // Uri for them or for some reason we were unable to save to the original Uri. In + // this case, we need to just save to internal file storage so that we can recover + val fileUri = Uri.fromFile(File(filesDir, viewModel.fileName.value)) + if (viewModel.save(this@MainActivity, fileUri)) { + fileUri + } else { + null + } + }?: return@withContext + sharedPrefs.edit() + .putString(getString(R.string.pref_key_autosave_uri), uri.toString()) + .apply() } } } @@ -89,10 +118,22 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_save -> requestFileOp(REQUEST_SAVE_FILE) + R.id.action_save -> { + launch { + if (!viewModel.save(this@MainActivity)) { + requestFileOp(REQUEST_SAVE_FILE) + } else { + Toast.makeText( + this@MainActivity, + getString(R.string.file_saved, viewModel.fileName.value), + Toast.LENGTH_SHORT + ).show() + } + } + } R.id.action_share -> { val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, presenter.markdown) + shareIntent.putExtra(Intent.EXTRA_TEXT, viewModel.markdownUpdates.value) shareIntent.type = "text/plain" startActivity(Intent.createChooser( shareIntent, @@ -137,9 +178,9 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes infoIntent.putExtra("title", title) launch { try { - val inputStream = assets?.open(fileName) + val html = assets?.readAssetToString(fileName) + ?.toHtml() ?: throw RuntimeException("Unable to open stream to $fileName") - val html = presenter.loadMarkdown(fileName, inputStream, false) infoIntent.putExtra("html", html) startActivity(infoIntent) } catch (e: Exception) { @@ -183,17 +224,11 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes return } - val fileName = contentResolver.query(data.data!!, null, null, null, null) - ?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.moveToFirst() - cursor.getString(nameIndex) - } ?: "Untitled.md" - - contentResolver.openFileDescriptor(data.data!!, "r")?.let { - val fileInput = FileInputStream(it.fileDescriptor) - launch { - presenter.loadMarkdown(fileName, fileInput) + launch { + val fileLoaded = viewModel.load(this@MainActivity, data.data) + if (!fileLoaded) { + Toast.makeText(this@MainActivity, R.string.file_load_error, Toast.LENGTH_SHORT) + .show() } } } @@ -202,20 +237,8 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes return } - val fileName = contentResolver.query(data.data!!, null, null, null, null) - ?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.moveToFirst() - cursor.getString(nameIndex) - } ?: "Untitled.md" - launch { - val outputStream = contentResolver.openOutputStream(data.data!!) - ?: throw RuntimeException("Unable to open output stream to save file") - presenter.saveMarkdown( - fileName, - outputStream - ) + viewModel.save(this@MainActivity, data.data) } } REQUEST_DARK_MODE -> recreate() @@ -224,11 +247,15 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes } private fun promptSaveOrDiscardChanges() { + if (viewModel.originalMarkdown.value == viewModel.markdownUpdates.value) { + viewModel.reset("Untitled.md") + return + } AlertDialog.Builder(this) .setTitle(R.string.save_changes) .setMessage(R.string.prompt_save_changes) .setNegativeButton(R.string.action_discard) { _, _ -> - presenter.newFile("Untitled.md") + viewModel.reset("Untitled.md") } .setPositiveButton(R.string.action_save) { _, _ -> requestFileOp(REQUEST_SAVE_FILE) @@ -252,7 +279,7 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes REQUEST_SAVE_FILE -> { Intent(Intent.ACTION_CREATE_DOCUMENT).apply { type = "text/markdown" - putExtra(Intent.EXTRA_TITLE, presenter.fileName) + putExtra(Intent.EXTRA_TITLE, viewModel.fileName.value) } } REQUEST_OPEN_FILE -> { @@ -274,14 +301,11 @@ class MainActivity : AppCompatActivity(), ActivityCompat.OnRequestPermissionsRes ) } - override fun onResume() { super.onResume() - title = presenter.fileName shouldAutoSave = true } - override fun onDestroy() { super.onDestroy() coroutineContext[Job]?.let { diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/SplashActivity.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/SplashActivity.kt index 5844389..85daafd 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/SplashActivity.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/view/activity/SplashActivity.kt @@ -1,85 +1,73 @@ package com.wbrawner.simplemarkdown.view.activity import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import android.preference.PreferenceManager import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModelProviders import com.wbrawner.simplemarkdown.MarkdownApplication import com.wbrawner.simplemarkdown.R -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter -import com.wbrawner.simplemarkdown.utility.ErrorHandler +import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import kotlinx.coroutines.* -import java.io.File -import java.io.FileNotFoundException -import javax.inject.Inject import kotlin.coroutines.CoroutineContext class SplashActivity : AppCompatActivity(), CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.Main - @Inject - lateinit var presenter: MarkdownPresenter - - @Inject - lateinit var errorHandler: ErrorHandler + lateinit var viewModel: MarkdownViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) - (application as MarkdownApplication).component.inject(this) - if (sharedPreferences.getBoolean(getString(R.string.error_reports_enabled), true)) { - errorHandler.init(this) - } + viewModel = ViewModelProviders.of( + this, + (application as MarkdownApplication).viewModelFactory + ).get(MarkdownViewModel::class.java) - val darkModeValue = sharedPreferences.getString( - getString(R.string.pref_key_dark_mode), - getString(R.string.pref_value_auto) - ) - - var darkMode = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - AppCompatDelegate.MODE_NIGHT_AUTO - } else { - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } - - if (darkModeValue != null && !darkModeValue.isEmpty()) { - if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) { - darkMode = AppCompatDelegate.MODE_NIGHT_NO - } else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) { - darkMode = AppCompatDelegate.MODE_NIGHT_YES - } - } - AppCompatDelegate.setDefaultNightMode(darkMode) - - if (intent?.data != null) { - launch { - presenter.loadFromUri(applicationContext, intent.data!!) - } - } else { - presenter.fileName = "Untitled.md" - val autosave = File(filesDir, "autosave.md") - if (autosave.exists()) { - try { - launch { - presenter.loadMarkdown( - "Untitled.md", - autosave.inputStream(), - true + launch { + val darkMode = withContext(Dispatchers.IO) { + val darkModeValue = PreferenceManager.getDefaultSharedPreferences(this@SplashActivity) + .getString( + getString(R.string.pref_key_dark_mode), + getString(R.string.pref_value_auto) ) - autosave.delete() - } - } catch (ignored: FileNotFoundException) { - return - } - } - } - val startIntent = Intent(this, MainActivity::class.java) - startActivity(startIntent) - finish() + return@withContext when { + darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_NO + darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true) -> AppCompatDelegate.MODE_NIGHT_YES + else -> { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + AppCompatDelegate.MODE_NIGHT_AUTO + } else { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } + } + + } + + AppCompatDelegate.setDefaultNightMode(darkMode) + withContext(Dispatchers.IO) { + var uri = intent?.data + if (uri == null) { + uri = PreferenceManager.getDefaultSharedPreferences(this@SplashActivity) + .getString( + getString(R.string.pref_key_autosave_uri), + null + )?.let { + Uri.parse(it) + } + } + + viewModel.load(this@SplashActivity, uri) + } + val startIntent = Intent(this@SplashActivity, MainActivity::class.java) + startActivity(startIntent) + finish() + } } override fun onDestroy() { diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt index 95b5001..fd3b39f 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/EditFragment.kt @@ -16,31 +16,24 @@ import android.view.ViewGroup import android.widget.EditText import android.widget.ScrollView import android.widget.TextView -import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders import com.wbrawner.simplemarkdown.MarkdownApplication import com.wbrawner.simplemarkdown.R import com.wbrawner.simplemarkdown.model.Readability -import com.wbrawner.simplemarkdown.presentation.MarkdownEditView -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter import com.wbrawner.simplemarkdown.utility.hideKeyboard import com.wbrawner.simplemarkdown.utility.showKeyboard import com.wbrawner.simplemarkdown.view.ViewPagerPage +import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel import kotlinx.coroutines.* -import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.math.abs -class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope { - @Inject - lateinit var presenter: MarkdownPresenter +class EditFragment : Fragment(), ViewPagerPage, CoroutineScope { private var markdownEditor: EditText? = null private var markdownEditorScroller: ScrollView? = null - override var markdown: String - get() = markdownEditor?.text?.toString() ?: "" - set(value) { - markdownEditor?.setText(value) - } + private lateinit var viewModel: MarkdownViewModel override val coroutineContext: CoroutineContext = Dispatchers.Main @SuppressLint("ClickableViewAccessibility") @@ -67,7 +60,7 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope delay(50) if (searchText != searchFor) return@launch - presenter.onMarkdownEdited(searchText) + viewModel.updateMarkdown(searchText) } } @@ -78,8 +71,15 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope } }) - val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val enableReadability = sharedPrefs.getBoolean(getString(R.string.readability_enabled), false) + var enableReadability = false + launch { + enableReadability = withContext(Dispatchers.IO) { + context?.let { + PreferenceManager.getDefaultSharedPreferences(it) + .getBoolean(getString(R.string.readability_enabled), false) + }?: false + } + } if (enableReadability) { markdownEditor?.addTextChangedListener(object : TextWatcher { private var previousValue = "" @@ -97,10 +97,10 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope if (searchText != searchFor) return@launch val start = System.currentTimeMillis() - if (markdown.isEmpty()) return@launch - if (previousValue == markdown) return@launch - val readability = Readability(markdown) - val span = SpannableString(markdown) + if (searchFor.isEmpty()) return@launch + if (previousValue == searchFor) return@launch + val readability = Readability(searchFor) + val span = SpannableString(searchFor) for (sentence in readability.sentences()) { var color = Color.TRANSPARENT if (sentence.syllableCount() > 25) color = Color.argb(100, 229, 232, 42) @@ -108,7 +108,7 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope span.setSpan(BackgroundColorSpan(color), sentence.start(), sentence.end(), 0) } markdownEditor?.setTextKeepState(span, TextView.BufferType.SPANNABLE) - previousValue = markdown + previousValue = searchFor val timeTakenMs = System.currentTimeMillis() - start Log.d("SimpleMarkdown", "Handled markdown in " + timeTakenMs + "ms") } @@ -148,21 +148,13 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - @Suppress("CAST_NEVER_SUCCEEDS") - (activity?.application as? MarkdownApplication)?.component?.inject(this) - presenter.editView = this@EditFragment - } - - override fun onResume() { - super.onResume() - presenter.editView = this - markdown = presenter.markdown - } - - override fun onPause() { - super.onPause() - presenter.editView = null - markdownEditor?.hideKeyboard() + viewModel = ViewModelProviders.of( + this, + (requireActivity().application as MarkdownApplication).viewModelFactory + ).get(MarkdownViewModel::class.java) + viewModel.originalMarkdown.observe(this, Observer { + markdownEditor?.setText(it) + }) } override fun onDestroy() { @@ -179,26 +171,4 @@ class EditFragment : Fragment(), MarkdownEditView, ViewPagerPage, CoroutineScope override fun onDeselected() { markdownEditor?.hideKeyboard() } - - override fun setTitle(title: String) { - val activity = activity - if (activity != null) { - activity.title = title - } - } - - override fun onFileSaved(success: Boolean) { - val message: String = if (success) { - getString(R.string.file_saved, presenter.fileName) - } else { - getString(R.string.file_save_error) - } - Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() - } - - override fun onFileLoaded(success: Boolean) { - // TODO: Investigate why this fires off so often - // int message = success ? R.string.file_loaded : R.string.file_load_error; - // Toast.makeText(getActivity(), message, Toast.LENGTH_SHORT).show(); - } -}// Required empty public constructor +} diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt index 806236f..0555d53 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/PreviewFragment.kt @@ -1,6 +1,5 @@ package com.wbrawner.simplemarkdown.view.fragment -import android.content.SharedPreferences import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle @@ -11,19 +10,20 @@ import android.view.ViewGroup import android.webkit.WebView import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders import com.wbrawner.simplemarkdown.BuildConfig import com.wbrawner.simplemarkdown.MarkdownApplication import com.wbrawner.simplemarkdown.R -import com.wbrawner.simplemarkdown.presentation.MarkdownPresenter -import com.wbrawner.simplemarkdown.presentation.MarkdownPreviewView -import javax.inject.Inject +import com.wbrawner.simplemarkdown.utility.toHtml +import com.wbrawner.simplemarkdown.viewmodel.MarkdownViewModel +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext - -class PreviewFragment : Fragment(), MarkdownPreviewView { - @Inject - lateinit var presenter: MarkdownPresenter +class PreviewFragment : Fragment(), CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.Main + lateinit var viewModel: MarkdownViewModel private var markdownPreview: WebView? = null - private var sharedPreferences: SharedPreferences? = null override fun onCreateView( inflater: LayoutInflater, @@ -32,46 +32,48 @@ class PreviewFragment : Fragment(), MarkdownPreviewView { ): View? = inflater.inflate(R.layout.fragment_preview, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(view.context) markdownPreview = view.findViewById(R.id.markdown_view) - (activity?.application as? MarkdownApplication)?.component?.inject(this) WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) } - override fun updatePreview(html: String) { - markdownPreview?.post { - val isNightMode = AppCompatDelegate.getDefaultNightMode() == - AppCompatDelegate.MODE_NIGHT_YES - || context!!.resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES - val defaultCssId = if (isNightMode) { - R.string.pref_custom_css_default_dark - } else { - R.string.pref_custom_css_default + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + viewModel = ViewModelProviders.of( + this, + (requireActivity().application as MarkdownApplication).viewModelFactory + ).get(MarkdownViewModel::class.java) + viewModel.markdownUpdates.observe(this, Observer { + markdownPreview?.post { + val isNightMode = AppCompatDelegate.getDefaultNightMode() == + AppCompatDelegate.MODE_NIGHT_YES + || context!!.resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES + val defaultCssId = if (isNightMode) { + R.string.pref_custom_css_default_dark + } else { + R.string.pref_custom_css_default + } + launch { + val css = withContext(Dispatchers.IO) { + @Suppress("ConstantConditionIf") + if (!BuildConfig.ENABLE_CUSTOM_CSS) { + requireActivity().getString(defaultCssId) + } else { + PreferenceManager.getDefaultSharedPreferences(requireActivity()) + .getString( + getString(R.string.pref_custom_css), + getString(defaultCssId) + )?: "" + } + } + val style = String.format(FORMAT_CSS, css) + markdownPreview?.loadDataWithBaseURL(null, + style + it.toHtml(), + "text/html", + "UTF-8", null + ) + } } - @Suppress("ConstantConditionIf") - val css: String? = if (!BuildConfig.ENABLE_CUSTOM_CSS) { - context?.getString(defaultCssId) - } else { - sharedPreferences!!.getString( - getString(R.string.pref_custom_css), - getString(defaultCssId) - ) - } - - val style = String.format(FORMAT_CSS, css) - - markdownPreview?.loadDataWithBaseURL(null, - style + html, - "text/html", - "UTF-8", null - ) - } - } - - override fun onResume() { - super.onResume() - presenter.previewView = this - presenter.onMarkdownEdited() + }) } override fun onDestroyView() { @@ -84,8 +86,10 @@ class PreviewFragment : Fragment(), MarkdownPreviewView { } override fun onDestroy() { + coroutineContext[Job]?.let { + cancel() + } super.onDestroy() - presenter.previewView = null } companion object { diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/SettingsFragment.kt b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/SettingsFragment.kt index 0657d61..7962e55 100644 --- a/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/SettingsFragment.kt +++ b/app/src/main/java/com/wbrawner/simplemarkdown/view/fragment/SettingsFragment.kt @@ -3,6 +3,7 @@ package com.wbrawner.simplemarkdown.view.fragment import android.content.SharedPreferences import android.os.Build import android.os.Bundle +import android.os.StrictMode import android.preference.ListPreference import android.preference.Preference import android.preference.PreferenceFragment @@ -13,19 +14,39 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatDelegate import com.wbrawner.simplemarkdown.BuildConfig import com.wbrawner.simplemarkdown.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.lang.Exception +import kotlin.coroutines.CoroutineContext -class SettingsFragment : PreferenceFragment(), SharedPreferences.OnSharedPreferenceChangeListener { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - addPreferencesFromResource(R.xml.pref_general) - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity) - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - setListPreferenceSummary( - sharedPreferences, - findPreference(getString(R.string.pref_key_dark_mode)) - ) - if (!BuildConfig.ENABLE_CUSTOM_CSS) { - preferenceScreen.removePreference(findPreference(getString(R.string.pref_custom_css))) +class SettingsFragment + : PreferenceFragment(), + SharedPreferences.OnSharedPreferenceChangeListener, + CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.Main + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + launch { + withContext(Dispatchers.IO) { + try { + // This can be thrown when recreating the activity for theme changes + addPreferencesFromResource(R.xml.pref_general) + } catch (ignored: Exception) { + return@withContext + } + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity) + sharedPreferences.registerOnSharedPreferenceChangeListener(this@SettingsFragment) + (findPreference(getString(R.string.pref_key_dark_mode)) as? ListPreference)?.let { + setListPreferenceSummary(sharedPreferences, it) + } + @Suppress("ConstantConditionIf") + if (!BuildConfig.ENABLE_CUSTOM_CSS) { + preferenceScreen.removePreference(findPreference(getString(R.string.pref_custom_css))) + } + } } } @@ -39,34 +60,35 @@ class SettingsFragment : PreferenceFragment(), SharedPreferences.OnSharedPrefere override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { if (!isAdded) return - val preference = findPreference(key) - if (preference is ListPreference) { - setListPreferenceSummary(sharedPreferences, preference) + val preference = findPreference(key) as? ListPreference ?: return + setListPreferenceSummary(sharedPreferences, preference) + if (preference.key != getString(R.string.pref_key_dark_mode)) { + return } - if (preference.key == getString(R.string.pref_key_dark_mode)) { - var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - AppCompatDelegate.MODE_NIGHT_AUTO - } else { - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } - val darkModeValue = sharedPreferences.getString(preference.key, null) - if (darkModeValue != null && !darkModeValue.isEmpty()) { - if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) { - darkMode = AppCompatDelegate.MODE_NIGHT_NO - } else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) { - darkMode = AppCompatDelegate.MODE_NIGHT_YES - } - } - AppCompatDelegate.setDefaultNightMode(darkMode) - activity?.recreate() + var darkMode: Int = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + AppCompatDelegate.MODE_NIGHT_AUTO + } else { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } + val darkModeValue = sharedPreferences.getString(preference.key, null) + if (darkModeValue != null && darkModeValue.isNotEmpty()) { + if (darkModeValue.equals(getString(R.string.pref_value_light), ignoreCase = true)) { + darkMode = AppCompatDelegate.MODE_NIGHT_NO + } else if (darkModeValue.equals(getString(R.string.pref_value_dark), ignoreCase = true)) { + darkMode = AppCompatDelegate.MODE_NIGHT_YES + } + } + AppCompatDelegate.setDefaultNightMode(darkMode) + activity?.recreate() } - private fun setListPreferenceSummary(sharedPreferences: SharedPreferences, preference: Preference) { - val listPreference = preference as ListPreference - val storedValue = sharedPreferences.getString(preference.getKey(), null) ?: return - val index = listPreference.findIndexOfValue(storedValue) + private fun setListPreferenceSummary(sharedPreferences: SharedPreferences, preference: ListPreference) { + val storedValue = sharedPreferences.getString( + preference.key, + null + ) ?: return + val index = preference.findIndexOfValue(storedValue) if (index < 0) return - preference.setSummary(listPreference.entries[index].toString()) + preference.summary = preference.entries[index].toString() } } diff --git a/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt b/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt new file mode 100644 index 0000000..e9f3896 --- /dev/null +++ b/app/src/main/java/com/wbrawner/simplemarkdown/viewmodel/MarkdownViewModel.kt @@ -0,0 +1,106 @@ +package com.wbrawner.simplemarkdown.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.wbrawner.simplemarkdown.utility.getName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream +import java.io.Reader +import kotlin.coroutines.CoroutineContext + +class MarkdownViewModel : ViewModel() { + private val coroutineContext: CoroutineContext = Dispatchers.IO + val fileName = MutableLiveData().apply { + postValue("Untitled.md") + } + val markdownUpdates = MutableLiveData() + val originalMarkdown = MutableLiveData() + val uri = MutableLiveData() + + fun updateMarkdown(markdown: String?) { + this.markdownUpdates.postValue(markdown ?: "") + } + + suspend fun load(context: Context, uri: Uri?): Boolean { + if (uri == null) return false + return withContext(Dispatchers.IO) { + context.contentResolver.openFileDescriptor(uri, "r")?.use { + val fileInput = FileInputStream(it.fileDescriptor) + val fileName = uri.getName(context) + return@withContext if (load(fileInput)) { + this@MarkdownViewModel.fileName.postValue(fileName) + this@MarkdownViewModel.uri.postValue(uri) + true + } else { + false + } + } ?: false + } + } + + suspend fun load(inputStream: InputStream): Boolean { + return try { + withContext(coroutineContext) { + val content = inputStream.reader().use(Reader::readText) + originalMarkdown.postValue(content) + markdownUpdates.postValue(content) + } + true + } catch (e: Throwable) { + e.printStackTrace() + false + } + } + + suspend fun save(context: Context, givenUri: Uri? = this.uri.value): Boolean { + val uri = givenUri ?: this.uri.value ?: return false + return withContext(Dispatchers.IO) { + val fileName = uri.getName(context) + val outputStream = context.contentResolver.openOutputStream(uri) + ?: return@withContext false + if (save(outputStream)) { + this@MarkdownViewModel.fileName.postValue(fileName) + this@MarkdownViewModel.uri.postValue(uri) + true + } else { + false + } + } + } + + suspend fun save(outputStream: OutputStream): Boolean { + return try { + withContext(coroutineContext) { + outputStream.writer().use { + it.write(markdownUpdates.value) + } + } + true + } catch (e: Throwable) { + false + } + } + + fun reset(untitledFileName: String) { + fileName.postValue(untitledFileName) + originalMarkdown.postValue("") + markdownUpdates.postValue("") + } +} + +class MarkdownViewModelFactory : ViewModelProvider.Factory { + private val markdownViewModel: MarkdownViewModel by lazy { + MarkdownViewModel() + } + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return markdownViewModel as T + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b40a96..c5e6249 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ light dark auto + autosave.uri Save Changes Would you like to save your changes? Discard diff --git a/app/src/main/res/xml/pref_general.xml b/app/src/main/res/xml/pref_general.xml index 8f392cd..98aa6b3 100644 --- a/app/src/main/res/xml/pref_general.xml +++ b/app/src/main/res/xml/pref_general.xml @@ -14,7 +14,7 @@