Adds email backup feature

realmasync
Laurent 3 years ago
parent d4078032a4
commit 703de02f26
  1. 3
      app/build.gradle
  2. 9
      app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt
  3. 62
      app/src/main/java/net/pokeranalytics/android/api/BackupApi.kt
  4. 242
      app/src/main/java/net/pokeranalytics/android/api/MultipartRequest.kt
  5. 16
      app/src/main/java/net/pokeranalytics/android/ui/extensions/UIExtensions.kt
  6. 10
      app/src/main/java/net/pokeranalytics/android/ui/fragment/SettingsFragment.kt
  7. 6
      app/src/main/java/net/pokeranalytics/android/ui/modules/data/EditableDataActivity.kt
  8. 5
      app/src/main/java/net/pokeranalytics/android/ui/modules/session/SessionActivity.kt
  9. 6
      app/src/main/java/net/pokeranalytics/android/ui/view/rows/SettingsRow.kt
  10. 76
      app/src/main/java/net/pokeranalytics/android/util/BackupOperator.kt
  11. 11
      app/src/main/java/net/pokeranalytics/android/util/Preferences.kt
  12. 2
      app/src/main/res/values-de/strings.xml
  13. 2
      app/src/main/res/values-es/strings.xml
  14. 3
      app/src/main/res/values-fr/strings.xml
  15. 2
      app/src/main/res/values-hi/strings.xml
  16. 2
      app/src/main/res/values-it/strings.xml
  17. 2
      app/src/main/res/values-ja/strings.xml
  18. 2
      app/src/main/res/values-pt/strings.xml
  19. 2
      app/src/main/res/values-ru/strings.xml
  20. 2
      app/src/main/res/values-zh/strings.xml
  21. 2
      app/src/main/res/values/strings.xml

@ -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'

@ -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}")

@ -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<Void>
}
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}")
}
}
}

@ -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<NetworkResponse?> {
private val twoHyphens = "--"
private val lineEnd = "\r\n"
private val boundary = "apiclient-" + System.currentTimeMillis()
private var mListener: Response.Listener<NetworkResponse>
private var mErrorListener: Response.ErrorListener
private var mHeaders: Map<String, String>? = null
private var byteData: Map<String, DataPart>? = 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<String, String>?,
byteData: Map<String, DataPart>,
listener: Response.Listener<NetworkResponse>,
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<NetworkResponse>,
errorListener: Response.ErrorListener
) : super(method, url, errorListener) {
mListener = listener
this.mErrorListener = errorListener
}
@Throws(AuthFailureError::class)
override fun getHeaders(): Map<String, String> {
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<NetworkResponse?> {
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<String, String>,
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<String, DataPart>) {
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
}
}
}

@ -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())

@ -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())

@ -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
*/

@ -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()

@ -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

@ -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<Session>? = null
private var transactions: RealmResults<Transaction>? = 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
}
}
}

@ -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)
}
}
}

@ -782,5 +782,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -786,6 +786,8 @@ La aplicación funciona con una suscripción anual para uso ilimitado, pero obti
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -791,5 +791,6 @@
<string name="expense">frais</string>
<string name="please_wait">Veuillez patienter…</string>
<string name="currency_rate_confirmation">Veuillez entrer le taux de %1$s vers %2$s pour l\'appliquer à toutes vos bankrolls</string>
<string name="backup_email">Email de sauvegarde</string>
<string name="backup_email_title">Renseignez votre email pour recevoir automatiquement vos sauvegardes à cette adresse</string>
</resources>

@ -781,5 +781,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -781,5 +781,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -785,5 +785,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…...</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -780,5 +780,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -782,5 +782,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -775,5 +775,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

@ -835,5 +835,7 @@
<string name="expense">expense</string>
<string name="please_wait">Please wait…</string>
<string name="currency_rate_confirmation">Please enter the %1$s to %2$s rate to apply to all your bankrolls</string>
<string name="backup_email">Email for sending backups</string>
<string name="backup_email_title">Fill in your email to automatically receive backups at that address</string>
</resources>

Loading…
Cancel
Save