From 703de02f2669abde60518323e954891ca729a400 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 2 May 2023 17:11:29 +0200 Subject: [PATCH] Adds email backup feature --- app/build.gradle | 3 + .../android/PokerAnalyticsApplication.kt | 9 +- .../pokeranalytics/android/api/BackupApi.kt | 62 +++++ .../android/api/MultipartRequest.kt | 242 ++++++++++++++++++ .../android/ui/extensions/UIExtensions.kt | 16 +- .../android/ui/fragment/SettingsFragment.kt | 10 + .../ui/modules/data/EditableDataActivity.kt | 6 +- .../ui/modules/session/SessionActivity.kt | 5 + .../android/ui/view/rows/SettingsRow.kt | 6 +- .../android/util/BackupOperator.kt | 76 ++++++ .../android/util/Preferences.kt | 11 +- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-hi/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 21 files changed, 456 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/net/pokeranalytics/android/api/BackupApi.kt create mode 100644 app/src/main/java/net/pokeranalytics/android/api/MultipartRequest.kt create mode 100644 app/src/main/java/net/pokeranalytics/android/util/BackupOperator.kt diff --git a/app/build.gradle b/app/build.gradle index d113fe0b..f2f7af7c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -155,6 +155,9 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.6.1' implementation "androidx.fragment:fragment-ktx:1.4.1" + // Retrofit + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + // Instrumented Tests androidTestImplementation 'androidx.test:core:1.3.0' androidTestImplementation 'androidx.test:runner:1.3.0' diff --git a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt index 23dee705..f8f62db6 100644 --- a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt +++ b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt @@ -14,10 +14,7 @@ import net.pokeranalytics.android.model.migrations.Patcher import net.pokeranalytics.android.model.migrations.PokerAnalyticsMigration import net.pokeranalytics.android.model.realm.Session import net.pokeranalytics.android.model.utils.Seed -import net.pokeranalytics.android.util.CrashLogging -import net.pokeranalytics.android.util.FakeDataManager -import net.pokeranalytics.android.util.PokerAnalyticsLogs -import net.pokeranalytics.android.util.UserDefaults +import net.pokeranalytics.android.util.* import net.pokeranalytics.android.util.billing.AppGuard import timber.log.Timber import java.util.* @@ -26,6 +23,7 @@ import java.util.* class PokerAnalyticsApplication : Application() { var reportWhistleBlower: ReportWhistleBlower? = null + var backupOperator: BackupOperator? = null companion object { @@ -83,6 +81,9 @@ class PokerAnalyticsApplication : Application() { // Report this.reportWhistleBlower = ReportWhistleBlower(this.applicationContext) + // Backups + this.backupOperator = BackupOperator(this.applicationContext) + // Infos val locale = Locale.getDefault() CrashLogging.log("Country: ${locale.country}, language: ${locale.language}") diff --git a/app/src/main/java/net/pokeranalytics/android/api/BackupApi.kt b/app/src/main/java/net/pokeranalytics/android/api/BackupApi.kt new file mode 100644 index 00000000..9be7d784 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/api/BackupApi.kt @@ -0,0 +1,62 @@ +package net.pokeranalytics.android.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import timber.log.Timber + +object RetrofitClient { + private const val BASE_URL = "https://www.pokeranalytics.net/backup/" + fun getClient(): Retrofit = + Retrofit.Builder() + .baseUrl(BASE_URL) + .build() + +} + +class BackupService { + private val retrofit = RetrofitClient.getClient() + val backupApi: MyBackupApi = retrofit.create(MyBackupApi::class.java) +} + +interface MyBackupApi { + @Multipart + @POST("send") + fun postFile(@Part mail: MultipartBody.Part, @Part fileBody: MultipartBody.Part): Call +} + + +object BackupApi { + + val service = BackupService() + + // curl -F recipient=laurent@staxriver.com -F file=@test.txt https://www.pokeranalytics.net/backup/send + fun backupFile(mail: String, fileName: String, fileContent: String) { + + val filePart = MultipartBody.Part.createFormData( + "file", + fileName, + RequestBody.create(MediaType.parse("text/csv"), fileContent) + ) + + val mailPart = MultipartBody.Part.createFormData("recipient", mail) + + CoroutineScope(context = Dispatchers.IO).launch { + + val response = service.backupApi.postFile(mailPart, filePart).execute() + Timber.d("response code = ${response.code()}") + Timber.d("success = ${response.isSuccessful}") + + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/pokeranalytics/android/api/MultipartRequest.kt b/app/src/main/java/net/pokeranalytics/android/api/MultipartRequest.kt new file mode 100644 index 00000000..af710bb5 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/api/MultipartRequest.kt @@ -0,0 +1,242 @@ +package net.pokeranalytics.android.api + +import com.android.volley.* +import com.android.volley.toolbox.HttpHeaderParser +import java.io.* + + +open class VolleyMultipartRequest : Request { + + private val twoHyphens = "--" + private val lineEnd = "\r\n" + private val boundary = "apiclient-" + System.currentTimeMillis() + private var mListener: Response.Listener + private var mErrorListener: Response.ErrorListener + private var mHeaders: Map? = null + private var byteData: Map? = null + + /** + * Default constructor with predefined header and post method. + * + * @param url request destination + * @param headers predefined custom header + * @param listener on success achieved 200 code from request + * @param errorListener on error http or library timeout + */ + constructor( + url: String?, headers: Map?, + byteData: Map, + listener: Response.Listener, + errorListener: Response.ErrorListener + ) : super(Method.POST, url, errorListener) { + mListener = listener + this.mErrorListener = errorListener + mHeaders = headers + this.byteData = byteData + } + + /** + * Constructor with option method and default header configuration. + * + * @param method method for now accept POST and GET only + * @param url request destination + * @param listener on success event handler + * @param errorListener on error event handler + */ + constructor( + method: Int, url: String?, + listener: Response.Listener, + errorListener: Response.ErrorListener + ) : super(method, url, errorListener) { + mListener = listener + this.mErrorListener = errorListener + } + + @Throws(AuthFailureError::class) + override fun getHeaders(): Map { + return if (mHeaders != null) mHeaders!! else super.getHeaders() + } + + override fun getBodyContentType(): String { + return "multipart/form-data;boundary=$boundary" + } + + @Throws(AuthFailureError::class) + override fun getBody(): ByteArray? { + val bos = ByteArrayOutputStream() + val dos = DataOutputStream(bos) + try { + // populate text payload + val params = params + if (params != null && params.isNotEmpty()) { + textParse(dos, params, paramsEncoding) + } + + // populate data byte payload + val data = + byteData + if (data != null && data.isNotEmpty()) { + dataParse(dos, data) + } + + // close multipart form data after text and file data + dos.writeBytes(twoHyphens + boundary + twoHyphens + lineEnd) + return bos.toByteArray() + } catch (e: IOException) { + e.printStackTrace() + } + return null + } + + override fun parseNetworkResponse(response: NetworkResponse?): Response { + return try { + Response.success( + response, + HttpHeaderParser.parseCacheHeaders(response) + ) + } catch (e: Exception) { + Response.error(ParseError(e)) + } + } + + override fun deliverResponse(response: NetworkResponse?) { + mListener.onResponse(response) + } + + override fun deliverError(error: VolleyError?) { + mErrorListener.onErrorResponse(error) + } + + /** + * Parse string map into data output stream by key and value. + * + * @param dataOutputStream data output stream handle string parsing + * @param params string inputs collection + * @param encoding encode the inputs, default UTF-8 + * @throws IOException + */ + @Throws(IOException::class) + private fun textParse( + dataOutputStream: DataOutputStream, + params: Map, + encoding: String + ) { + try { + for ((key, value) in params) { + buildTextPart(dataOutputStream, key, value) + } + } catch (uee: UnsupportedEncodingException) { + throw RuntimeException("Encoding not supported: $encoding", uee) + } + } + + /** + * Parse data into data output stream. + * + * @param dataOutputStream data output stream handle file attachment + * @param data loop through data + * @throws IOException + */ + @Throws(IOException::class) + private fun dataParse(dataOutputStream: DataOutputStream, data: Map) { + for ((key, value) in data) { + buildDataPart(dataOutputStream, value, key) + } + } + + /** + * Write string data into header and data output stream. + * + * @param dataOutputStream data output stream handle string parsing + * @param parameterName name of input + * @param parameterValue value of input + * @throws IOException + */ + @Throws(IOException::class) + private fun buildTextPart( + dataOutputStream: DataOutputStream, + parameterName: String, + parameterValue: String + ) { + dataOutputStream.writeBytes(twoHyphens + boundary + lineEnd) + dataOutputStream.writeBytes("Content-Disposition: form-data; name=\"$parameterName\"$lineEnd") + //dataOutputStream.writeBytes("Content-Type: text/plain; charset=UTF-8" + lineEnd); + dataOutputStream.writeBytes(lineEnd) + dataOutputStream.writeBytes(parameterValue + lineEnd) + } + + /** + * Write data file into header and data output stream. + * + * @param dataOutputStream data output stream handle data parsing + * @param dataFile data byte as DataPart from collection + * @param inputName name of data input + * @throws IOException + */ + @Throws(IOException::class) + private fun buildDataPart( + dataOutputStream: DataOutputStream, + dataFile: DataPart, + inputName: String + ) { + dataOutputStream.writeBytes(twoHyphens + boundary + lineEnd) + dataOutputStream.writeBytes( + "Content-Disposition: form-data; name=\"" + + inputName + "\"; filename=\"" + dataFile.fileName + "\"" + lineEnd + ) + if (dataFile.type != null && !dataFile.type!!.trim { it <= ' ' }.isEmpty()) { + dataOutputStream.writeBytes("Content-Type: " + dataFile.type + lineEnd) + } + dataOutputStream.writeBytes(lineEnd) + val fileInputStream = ByteArrayInputStream(dataFile.content) + var bytesAvailable: Int = fileInputStream.available() + val maxBufferSize = 1024 * 1024 + var bufferSize = Math.min(bytesAvailable, maxBufferSize) + val buffer = ByteArray(bufferSize) + var bytesRead: Int = fileInputStream.read(buffer, 0, bufferSize) + while (bytesRead > 0) { + dataOutputStream.write(buffer, 0, bufferSize) + bytesAvailable = fileInputStream.available() + bufferSize = Math.min(bytesAvailable, maxBufferSize) + bytesRead = fileInputStream.read(buffer, 0, bufferSize) + } + dataOutputStream.writeBytes(lineEnd) + } + + /** + * Simple data container use for passing byte file + */ + class DataPart { + + var fileName: String? = null + + var content: ByteArray? = null + + var type: String? = null + + /** + * Constructor with data. + * + * @param name label of data + * @param data byte data + */ + constructor(name: String?, data: ByteArray) { + fileName = name + content = data + } + + /** + * Constructor with mime data type. + * + * @param name label of data + * @param data byte data + * @param mimeType mime data like "image/jpeg" + */ + constructor(name: String?, data: ByteArray, mimeType: String?) { + fileName = name + content = data + type = mimeType + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/pokeranalytics/android/ui/extensions/UIExtensions.kt b/app/src/main/java/net/pokeranalytics/android/ui/extensions/UIExtensions.kt index ca0b2b19..74828301 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/extensions/UIExtensions.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/extensions/UIExtensions.kt @@ -163,14 +163,26 @@ fun showEditTextAlertDialog( builder.setMessage(it) } + val layout = LinearLayout(context) + layout.orientation = LinearLayout.VERTICAL + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ) + params.setMargins(20, 0, 30, 0) + val editText = EditText(context) + editText.inputType = inputType + editTextText?.let { editText.text = SpannableStringBuilder(it) } editText.setTextColor(ContextCompat.getColor(context, R.color.white)) - editText.inputType = inputType - builder.setView(editText) + layout.addView(editText, params) + + builder.setView(layout) + +// builder.setView(editText) builder.setPositiveButton(net.pokeranalytics.android.R.string.ok) { _, _ -> positiveAction?.invoke(editText.text.toString()) diff --git a/app/src/main/java/net/pokeranalytics/android/ui/fragment/SettingsFragment.kt b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SettingsFragment.kt index e1cd64e8..24b6f760 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/fragment/SettingsFragment.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SettingsFragment.kt @@ -211,6 +211,7 @@ class SettingsFragment : RealmFragment(), RowRepresentableDelegate, StaticRowRep SettingsRow.SUBSCRIPTION -> AppGuard.subscriptionStatus(requireContext()) SettingsRow.VERSION -> BuildConfig.VERSION_NAME + if (BuildConfig.DEBUG) " (${BuildConfig.VERSION_CODE}) DEBUG" else "" SettingsRow.CURRENCY -> UserDefaults.currency.symbol + SettingsRow.BACKUP_EMAIL -> Preferences.getBackupEmail(requireContext()) ?: "" else -> "" } } @@ -241,6 +242,7 @@ class SettingsFragment : RealmFragment(), RowRepresentableDelegate, StaticRowRep SettingsRow.BUG_REPORT -> parentActivity?.openContactMail(R.string.bug_report_subject, Realm.getDefaultInstance().path) SettingsRow.CURRENCY -> CurrenciesActivity.newInstanceForResult(this@SettingsFragment, RequestCode.CURRENCY.value) SettingsRow.DEALT_HANDS_PER_HOUR -> DealtHandsPerHourActivity.newInstance(requireContext()) + SettingsRow.BACKUP_EMAIL -> this.editBackupEmail() SettingsRow.EXPORT_CSV_SESSIONS -> this.sessionsCSVExport() SettingsRow.EXPORT_CSV_TRANSACTIONS -> this.transactionsCSVExport() SettingsRow.FOLLOW_US -> { @@ -264,6 +266,14 @@ class SettingsFragment : RealmFragment(), RowRepresentableDelegate, StaticRowRep } } + private fun editBackupEmail() { + context?.let { context -> + showEditTextAlertDialog(context, InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, messageResId = R.string.backup_email_title, editTextText = Preferences.getBackupEmail(context)) { value -> + Preferences.setBackupEmail(value, context) + } + } + } + private fun showReviewManager() { val manager = ReviewManagerFactory.create(requireContext()) diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/data/EditableDataActivity.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/data/EditableDataActivity.kt index 405ba114..a29412ad 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/data/EditableDataActivity.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/data/EditableDataActivity.kt @@ -8,7 +8,6 @@ import net.pokeranalytics.android.R import net.pokeranalytics.android.model.LiveData import net.pokeranalytics.android.ui.activity.components.MediaActivity import java.io.File -import java.util.* class EditableDataActivity : MediaActivity() { @@ -52,6 +51,11 @@ class EditableDataActivity : MediaActivity() { initUI() } + override fun onPause() { + super.onPause() + this.paApplication.backupOperator?.backupIfNecessary() + } + /** * Init UI */ diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionActivity.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionActivity.kt index 6f37ee95..12cc93b3 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionActivity.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionActivity.kt @@ -68,6 +68,11 @@ class SessionActivity: BaseActivity() { initUI() } + override fun onPause() { + super.onPause() + this.paApplication.backupOperator?.backupIfNecessary() + } + override fun onBackPressed() { setResult(Activity.RESULT_OK) super.onBackPressed() diff --git a/app/src/main/java/net/pokeranalytics/android/ui/view/rows/SettingsRow.kt b/app/src/main/java/net/pokeranalytics/android/ui/view/rows/SettingsRow.kt index 2ed81e65..ad0744fe 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/view/rows/SettingsRow.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/view/rows/SettingsRow.kt @@ -37,6 +37,7 @@ enum class SettingsRow : RowRepresentable { CURRENCY, DEALT_HANDS_PER_HOUR, SHOW_INAPP_BADGES, + BACKUP_EMAIL, // Export EXPORT_CSV_SESSIONS, @@ -80,7 +81,7 @@ enum class SettingsRow : RowRepresentable { rows.addAll(arrayListOf(FOLLOW_US, DISCORD, BLOG_TIPS, SHOULD_SHOW_BLOG_TIPS)) rows.add(CustomizableRowRepresentable(customViewType = RowViewType.HEADER_TITLE, resId = R.string.preferences)) - rows.addAll(arrayListOf(CURRENCY, DEALT_HANDS_PER_HOUR, SHOW_INAPP_BADGES)) + rows.addAll(arrayListOf(CURRENCY, DEALT_HANDS_PER_HOUR, BACKUP_EMAIL, SHOW_INAPP_BADGES)) rows.add(CustomizableRowRepresentable(customViewType = RowViewType.HEADER_TITLE, resId = R.string.export)) rows.addAll(arrayListOf(EXPORT_CSV_SESSIONS, EXPORT_CSV_TRANSACTIONS)) @@ -131,6 +132,7 @@ enum class SettingsRow : RowRepresentable { DISCORD -> R.string.join_discord DEALT_HANDS_PER_HOUR -> R.string.dealt_hands_per_hour SHOW_INAPP_BADGES -> R.string.show_inapp_badges + BACKUP_EMAIL -> R.string.backup_email else -> null } } @@ -141,7 +143,7 @@ enum class SettingsRow : RowRepresentable { return when (this) { BANKROLL_REPORT, TOP_10, PLAYERS -> RowViewType.TITLE_ICON_ARROW.ordinal VERSION, SUBSCRIPTION -> RowViewType.TITLE_VALUE.ordinal - LANGUAGE, CURRENCY -> RowViewType.TITLE_VALUE_ARROW.ordinal + LANGUAGE, CURRENCY, BACKUP_EMAIL -> RowViewType.TITLE_VALUE_ARROW.ordinal FOLLOW_US -> RowViewType.ROW_FOLLOW_US.ordinal STOP_NOTIFICATION, SHOULD_SHOW_BLOG_TIPS, SHOW_INAPP_BADGES -> RowViewType.TITLE_SWITCH.ordinal STOP_NOTIFICATION_MESSAGE -> RowViewType.INFO.ordinal diff --git a/app/src/main/java/net/pokeranalytics/android/util/BackupOperator.kt b/app/src/main/java/net/pokeranalytics/android/util/BackupOperator.kt new file mode 100644 index 00000000..a74a79a3 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/util/BackupOperator.kt @@ -0,0 +1,76 @@ +package net.pokeranalytics.android.util + +import android.content.Context +import io.realm.Realm +import io.realm.RealmResults +import net.pokeranalytics.android.api.BackupApi +import net.pokeranalytics.android.model.realm.Session +import net.pokeranalytics.android.model.realm.Transaction +import net.pokeranalytics.android.util.csv.ProductCSVDescriptors +import net.pokeranalytics.android.util.extensions.dateTimeFileFormatted +import timber.log.Timber +import java.util.* + +class BackupOperator(var context: Context) { + + private var sessions: RealmResults? = null + private var transactions: RealmResults? = null + + private var sessionsChanged = false + private var transactionsChanged = false + + private val realm = Realm.getDefaultInstance() + + init { + + this.sessions = this.realm.where(Session::class.java).findAll() + this.sessions?.addChangeListener { _ -> + sessionsChanged = true +// backupIfNecessary() + } + + this.transactions = this.realm.where(Transaction::class.java).findAll() + this.transactions?.addChangeListener { _ -> + transactionsChanged = true + } + + } + + fun backupIfNecessary() { + + Timber.d(">>> backupIfNecessary") + + Preferences.getBackupEmail(context)?.let { email -> + this.backupSessionsIfNecessary(email) + this.backupTransactionsIfNecessary(email) + } + + } + + private fun backupSessionsIfNecessary(email: String) { + + if (this.sessionsChanged) { + Timber.d(">>>> backup sessions") + val sessions = this.realm.where(Session::class.java).findAll().sort("startDate") + val csv = ProductCSVDescriptors.pokerAnalyticsAndroid6Sessions.toCSV(sessions) + val fileName = "sessions_${Date().dateTimeFileFormatted}.csv" + BackupApi.backupFile(email, fileName, csv) + this.sessionsChanged = false + } + + } + + private fun backupTransactionsIfNecessary(email: String) { + + if (this.transactionsChanged) { + Timber.d(">>>> backup transactions") + val transactions = this.realm.where(Transaction::class.java).findAll().sort("date") + val csv = ProductCSVDescriptors.pokerAnalyticsAndroidTransactions.toCSV(transactions) + val fileName = "transactions_${Date().dateTimeFileFormatted}.csv" + BackupApi.backupFile(email, fileName, csv) + this.transactionsChanged = false + } + + } + +} diff --git a/app/src/main/java/net/pokeranalytics/android/util/Preferences.kt b/app/src/main/java/net/pokeranalytics/android/util/Preferences.kt index bb78ed43..24362e6b 100644 --- a/app/src/main/java/net/pokeranalytics/android/util/Preferences.kt +++ b/app/src/main/java/net/pokeranalytics/android/util/Preferences.kt @@ -49,7 +49,8 @@ class Preferences { CLEAN_BLINDS_FILTERS("deleteBlindsFilters"), SHOW_IN_APP_BADGES("showInAppBadges"), LAST_CALENDAR_BADGE_DATE("lastCalendarBadgeDate"), - PATCH_RATED_AMOUNT("patchRatedAmount[new field]") + PATCH_RATED_AMOUNT("patchRatedAmount[new field]"), + BACKUP_EMAIL("backupEmail") } enum class FeedMessage { @@ -327,6 +328,14 @@ class Preferences { setLong(Keys.LAST_CALENDAR_BADGE_DATE, date, context) } + fun setBackupEmail(email: String, context: Context) { + setString(Keys.BACKUP_EMAIL, email, context) + } + + fun getBackupEmail(context: Context): String? { + return getString(Keys.BACKUP_EMAIL, context) + } + } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b9e3a8b4..562e6155 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -782,5 +782,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 207d3638..bfaf64f5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -786,6 +786,8 @@ La aplicación funciona con una suscripción anual para uso ilimitado, pero obti expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b9ad5849..bdec2f49 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -791,5 +791,6 @@ frais Veuillez patienter… Veuillez entrer le taux de %1$s vers %2$s pour l\'appliquer à toutes vos bankrolls - + Email de sauvegarde + Renseignez votre email pour recevoir automatiquement vos sauvegardes à cette adresse diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e0422d56..8e5686ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -781,5 +781,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 357687d5..9bfccf29 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -781,5 +781,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 2de4c802..70444daa 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -785,5 +785,7 @@ expense Please wait…... Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 275a6332..bc95eead 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -780,5 +780,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 46cef7d4..f5eeac9c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -782,5 +782,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 019fe926..f9cca47a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -775,5 +775,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfd71e18..74fbf706 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -835,5 +835,7 @@ expense Please wait… Please enter the %1$s to %2$s rate to apply to all your bankrolls + Email for sending backups + Fill in your email to automatically receive backups at that address