parent
d4078032a4
commit
703de02f26
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue