Compare commits
251 Commits
@ -0,0 +1,139 @@ |
|||||||
|
# CLAUDE.md |
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
||||||
|
|
||||||
|
## Project Overview |
||||||
|
|
||||||
|
This is **Poker Analytics**, a comprehensive Android application for tracking and analyzing poker sessions. The app supports both cash games and tournaments, providing detailed statistics, reporting, and data visualization capabilities. |
||||||
|
|
||||||
|
### Key Features |
||||||
|
- Session tracking (cash games and tournaments) |
||||||
|
- Advanced filtering and reporting |
||||||
|
- Bankroll management |
||||||
|
- Hand history import and replay |
||||||
|
- Statistical analysis and charting |
||||||
|
- Data backup and export functionality |
||||||
|
- Multi-currency support |
||||||
|
|
||||||
|
## Development Commands |
||||||
|
|
||||||
|
### Building the Project |
||||||
|
```bash |
||||||
|
./gradlew assembleStandardRelease # Build release APK |
||||||
|
./gradlew assembleStandardDebug # Build debug APK |
||||||
|
./gradlew build # Build all variants |
||||||
|
``` |
||||||
|
|
||||||
|
### Running Tests |
||||||
|
```bash |
||||||
|
./gradlew test # Run unit tests |
||||||
|
./gradlew connectedAndroidTest # Run instrumented tests (requires device/emulator) |
||||||
|
./gradlew testStandardDebugUnitTest # Run specific unit tests |
||||||
|
``` |
||||||
|
|
||||||
|
### Cleaning |
||||||
|
```bash |
||||||
|
./gradlew clean # Clean build artifacts |
||||||
|
``` |
||||||
|
|
||||||
|
### Build Configuration |
||||||
|
- **Target SDK**: 35 (Android 15) |
||||||
|
- **Min SDK**: 23 (Android 6.0) |
||||||
|
- **Build Tools**: 30.0.3 |
||||||
|
- **Kotlin Version**: 1.9.24 |
||||||
|
- **Realm Schema Version**: 14 |
||||||
|
|
||||||
|
## Architecture Overview |
||||||
|
|
||||||
|
### Package Structure |
||||||
|
The main source code is organized under `app/src/main/java/net/pokeranalytics/android/`: |
||||||
|
|
||||||
|
#### Core Components |
||||||
|
- **`model/`** - Data models and business logic |
||||||
|
- `realm/` - Realm database models (Session, Bankroll, Result, etc.) |
||||||
|
- `filter/` - Query system for filtering sessions |
||||||
|
- `migrations/` - Database migration handling |
||||||
|
- `handhistory/` - Hand history data structures |
||||||
|
|
||||||
|
- **`ui/`** - User interface components |
||||||
|
- `activity/` - Main activities (HomeActivity, SessionActivity, etc.) |
||||||
|
- `fragment/` - UI fragments organized by feature |
||||||
|
- `adapter/` - RecyclerView adapters and data sources |
||||||
|
- `modules/` - Feature-specific UI modules |
||||||
|
|
||||||
|
- **`calculus/`** - Statistics and calculation engine |
||||||
|
- Core calculation logic for poker statistics |
||||||
|
- Report generation system |
||||||
|
- Performance tracking |
||||||
|
|
||||||
|
- **`util/`** - Utility classes |
||||||
|
- `csv/` - CSV import/export functionality |
||||||
|
- `billing/` - In-app purchase handling |
||||||
|
- `extensions/` - Kotlin extension functions |
||||||
|
|
||||||
|
#### Key Classes |
||||||
|
- **`Session`** (`model/realm/Session.kt`): Core session data model |
||||||
|
- **`HomeActivity`** (`ui/activity/HomeActivity.kt`): Main app entry point with tab navigation |
||||||
|
- **`PokerAnalyticsApplication`**: Application class handling initialization |
||||||
|
|
||||||
|
### Database Architecture |
||||||
|
The app uses **Realm** database with these key entities: |
||||||
|
- `Session` - Individual poker sessions |
||||||
|
- `Bankroll` - Bankroll management |
||||||
|
- `Result` - Session results and statistics |
||||||
|
- `ComputableResult` - Pre-computed statistics for performance |
||||||
|
- `Filter` - Saved filter configurations |
||||||
|
- `HandHistory` - Hand-by-hand game data |
||||||
|
|
||||||
|
### UI Architecture |
||||||
|
- **MVVM pattern** with Android Architecture Components |
||||||
|
- **Fragment-based navigation** with bottom navigation tabs |
||||||
|
- **Custom RecyclerView adapters** for data presentation |
||||||
|
- **Material Design** components |
||||||
|
|
||||||
|
## Key Technologies |
||||||
|
|
||||||
|
### Core Dependencies |
||||||
|
- **Realm Database** (10.15.1) - Local data storage |
||||||
|
- **Kotlin Coroutines** - Asynchronous programming |
||||||
|
- **Firebase Crashlytics** - Crash reporting and analytics |
||||||
|
- **Material Design Components** - UI framework |
||||||
|
- **MPAndroidChart** - Data visualization |
||||||
|
- **CameraX** - Image capture functionality |
||||||
|
|
||||||
|
### Testing |
||||||
|
- **JUnit** for unit testing |
||||||
|
- **Android Instrumented Tests** for integration testing |
||||||
|
- Test files located in `app/src/androidTest/` and `app/src/test/` |
||||||
|
|
||||||
|
## Development Guidelines |
||||||
|
|
||||||
|
### Working with Sessions |
||||||
|
- Sessions are the core data model representing individual poker games |
||||||
|
- Use `Session.newInstance()` to create new sessions properly |
||||||
|
- Always call `computeStats()` after modifying session data |
||||||
|
- Sessions can be in various states: PENDING, STARTED, PAUSED, ENDED |
||||||
|
|
||||||
|
### Database Operations |
||||||
|
- Use Realm transactions for data modifications |
||||||
|
- The app uses schema version 14 - increment when making schema changes |
||||||
|
- Migration logic is in `PokerAnalyticsMigration.kt` |
||||||
|
|
||||||
|
### Testing Data |
||||||
|
- Use `FakeDataManager.createFakeSessions()` for generating test data |
||||||
|
- Seed data is available through the `Seed` class |
||||||
|
|
||||||
|
### Build Variants |
||||||
|
- **standard** - Main production flavor |
||||||
|
- Release builds are optimized and obfuscated with ProGuard |
||||||
|
|
||||||
|
## Performance Considerations |
||||||
|
- Sessions use `ComputableResult` for pre-computed statistics |
||||||
|
- Large datasets are handled with Realm's lazy loading |
||||||
|
- Chart rendering is optimized for large data sets |
||||||
|
- Background processing uses Kotlin Coroutines |
||||||
|
|
||||||
|
## Security & Privacy |
||||||
|
- Sensitive data is encrypted in Realm database |
||||||
|
- Crash logging excludes personal information |
||||||
|
- Backup functionality includes data encryption |
||||||
|
After Width: | Height: | Size: 161 KiB |
@ -0,0 +1,75 @@ |
|||||||
|
package net.pokeranalytics.android.api |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.async |
||||||
|
import net.pokeranalytics.android.util.CrashLogging |
||||||
|
import net.pokeranalytics.android.util.extensions.isNetworkAvailable |
||||||
|
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 { |
||||||
|
|
||||||
|
private val service = BackupService() |
||||||
|
|
||||||
|
// curl -F recipient=laurent@staxriver.com -F file=@test.txt https://www.pokeranalytics.net/backup/send |
||||||
|
suspend fun backupFile(context: Context, mail: String, fileName: String, fileContent: String): Boolean { |
||||||
|
|
||||||
|
val filePart = MultipartBody.Part.createFormData( |
||||||
|
"file", |
||||||
|
fileName, |
||||||
|
RequestBody.create(MediaType.parse("text/csv"), fileContent) |
||||||
|
) |
||||||
|
|
||||||
|
val mailPart = MultipartBody.Part.createFormData("recipient", mail) |
||||||
|
|
||||||
|
return if (context.isNetworkAvailable()) { |
||||||
|
var success = false |
||||||
|
val job = CoroutineScope(context = Dispatchers.IO).async { |
||||||
|
success = try { |
||||||
|
val response = service.backupApi.postFile(mailPart, filePart).execute() |
||||||
|
Timber.d("response code = ${response.code()}") |
||||||
|
Timber.d("success = ${response.isSuccessful}") |
||||||
|
true |
||||||
|
} catch (e: Exception) { |
||||||
|
Timber.d("!!! backup failed: ${e.message}") |
||||||
|
CrashLogging.logException(e) |
||||||
|
false |
||||||
|
} |
||||||
|
} |
||||||
|
job.await() |
||||||
|
return success |
||||||
|
} else { |
||||||
|
false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
package net.pokeranalytics.android.api |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import com.android.volley.Request |
||||||
|
import com.android.volley.toolbox.JsonArrayRequest |
||||||
|
import com.android.volley.toolbox.Volley |
||||||
|
import org.json.JSONArray |
||||||
|
import timber.log.Timber |
||||||
|
|
||||||
|
data class BlogPost(var id: Int, var content: String) |
||||||
|
|
||||||
|
private fun JSONArray.toBlogPosts(): List<BlogPost> { |
||||||
|
|
||||||
|
val posts = mutableListOf<BlogPost>() |
||||||
|
(0 until this.length()).forEach { index -> |
||||||
|
val jo = this.getJSONObject(index) |
||||||
|
val post = BlogPost(jo.getInt("id"), jo.getJSONObject("content").getString("rendered")) |
||||||
|
posts.add(post) |
||||||
|
} |
||||||
|
return posts |
||||||
|
} |
||||||
|
|
||||||
|
class BlogPostApi { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
private const val tipsLastPostsURL = "https://www.poker-analytics.net/blog/wp-json/wp/v2/posts/?categories=109\n" |
||||||
|
|
||||||
|
fun getLatestPosts(context: Context, callback: (List<BlogPost>) -> (Unit)) { |
||||||
|
|
||||||
|
val queue = Volley.newRequestQueue(context) |
||||||
|
|
||||||
|
val jsonObjectRequest = JsonArrayRequest( |
||||||
|
Request.Method.GET, tipsLastPostsURL, null, |
||||||
|
{ response -> |
||||||
|
// Timber.d("posts = $response") |
||||||
|
callback(response.toBlogPosts()) |
||||||
|
}, |
||||||
|
{ error -> |
||||||
|
Timber.w("Error while retrieving blog posts: $error") |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
queue.add(jsonObjectRequest) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
package net.pokeranalytics.android.api |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import androidx.annotation.Keep |
||||||
|
import com.android.volley.VolleyError |
||||||
|
import com.android.volley.toolbox.StringRequest |
||||||
|
import com.android.volley.toolbox.Volley |
||||||
|
import kotlinx.serialization.Serializable |
||||||
|
import kotlinx.serialization.decodeFromString |
||||||
|
import kotlinx.serialization.json.Json |
||||||
|
import timber.log.Timber |
||||||
|
|
||||||
|
@Keep |
||||||
|
@Serializable |
||||||
|
data class RateResponse(var info: RateInfo) |
||||||
|
|
||||||
|
@Keep |
||||||
|
@Serializable |
||||||
|
data class RateInfo(var rate: Double) |
||||||
|
|
||||||
|
class CurrencyConverterApi { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
val json = Json { ignoreUnknownKeys = true } |
||||||
|
|
||||||
|
fun currencyRate(fromCurrency: String, toCurrency: String, context: Context, callback: (Double?, VolleyError?) -> (Unit)) { |
||||||
|
|
||||||
|
val queue = Volley.newRequestQueue(context) |
||||||
|
val url = "https://api.apilayer.com/exchangerates_data/convert?to=$toCurrency&from=$fromCurrency&amount=1" |
||||||
|
|
||||||
|
Timber.d("Api call = $url") |
||||||
|
|
||||||
|
val stringRequest = object : StringRequest( |
||||||
|
Method.GET, url, |
||||||
|
{ response -> |
||||||
|
|
||||||
|
val o = json.decodeFromString<RateResponse>(response) |
||||||
|
Timber.d("rate = ${o.info.rate}") |
||||||
|
callback(o.info.rate, null) |
||||||
|
}, |
||||||
|
{ |
||||||
|
Timber.d("Api call failed: ${it.message}") |
||||||
|
callback(null, it) |
||||||
|
}) { |
||||||
|
|
||||||
|
override fun getHeaders(): MutableMap<String, String> { |
||||||
|
val headers = HashMap<String, String>() |
||||||
|
headers["apikey"] = "XnfeyID3PMKd3k4zTPW0XmZAbcZlZgqH" |
||||||
|
return headers |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
queue.add(stringRequest) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,47 +0,0 @@ |
|||||||
package net.pokeranalytics.android.api |
|
||||||
|
|
||||||
import android.content.Context |
|
||||||
import com.android.volley.Request |
|
||||||
import com.android.volley.Response |
|
||||||
import com.android.volley.toolbox.StringRequest |
|
||||||
import com.android.volley.toolbox.Volley |
|
||||||
import kotlinx.serialization.json.Json |
|
||||||
import kotlinx.serialization.json.JsonConfiguration |
|
||||||
import timber.log.Timber |
|
||||||
|
|
||||||
class FreeConverterApi { |
|
||||||
|
|
||||||
companion object { |
|
||||||
|
|
||||||
fun currencyRate(pair: String, context: Context, callback: (Double) -> (Unit)) { |
|
||||||
|
|
||||||
val queue = Volley.newRequestQueue(context) |
|
||||||
val url = "https://free.currconv.com/api/v7/convert?q=${pair}&compact=ultra&apiKey=5ba8d38995282fe8b1c8" |
|
||||||
|
|
||||||
// https://free.currconv.com/api/v7/convert?q=GBP_USD&compact=ultra&apiKey=5ba8d38995282fe8b1c8 |
|
||||||
// { "USD_PHP": 44.1105, "PHP_USD": 0.0227 } |
|
||||||
|
|
||||||
val stringRequest = StringRequest( |
|
||||||
Request.Method.GET, url, |
|
||||||
Response.Listener { response -> |
|
||||||
|
|
||||||
val json = Json(JsonConfiguration.Stable) |
|
||||||
val f = json.parseJson(response) |
|
||||||
f.jsonObject[pair]?.primitive?.double?.let { rate -> |
|
||||||
callback(rate) |
|
||||||
} ?: run { |
|
||||||
Timber.d("no rate: $response") |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
Response.ErrorListener { |
|
||||||
Timber.d("Api call failed: ${it.message}") |
|
||||||
}) |
|
||||||
|
|
||||||
queue.add(stringRequest) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -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,338 @@ |
|||||||
|
package net.pokeranalytics.android.calculus |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import android.os.CountDownTimer |
||||||
|
import io.realm.Realm |
||||||
|
import io.realm.RealmQuery |
||||||
|
import io.realm.RealmResults |
||||||
|
import kotlinx.coroutines.CoroutineScope |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.launch |
||||||
|
import net.pokeranalytics.android.calculus.optimalduration.CashGameOptimalDurationCalculator |
||||||
|
import net.pokeranalytics.android.model.LiveOnline |
||||||
|
import net.pokeranalytics.android.model.realm.* |
||||||
|
import net.pokeranalytics.android.ui.view.rows.StaticReport |
||||||
|
import net.pokeranalytics.android.util.CrashLogging |
||||||
|
import net.pokeranalytics.android.util.extensions.formattedHourlyDuration |
||||||
|
import timber.log.Timber |
||||||
|
import kotlin.coroutines.CoroutineContext |
||||||
|
|
||||||
|
|
||||||
|
interface NewPerformanceListener { |
||||||
|
fun newBestPerformanceHandler() |
||||||
|
} |
||||||
|
|
||||||
|
class ReportWhistleBlower(var context: Context) { |
||||||
|
|
||||||
|
private var sessions: RealmResults<Session>? = null |
||||||
|
private var results: RealmResults<Result>? = null |
||||||
|
|
||||||
|
private var currentTask: ReportTask? = null |
||||||
|
|
||||||
|
private val currentNotifications: MutableList<String> = mutableListOf() // Performance.id |
||||||
|
|
||||||
|
private val listeners: MutableList<NewPerformanceListener> = mutableListOf() |
||||||
|
|
||||||
|
var paused: Boolean = false |
||||||
|
|
||||||
|
private var timer: CountDownTimer? = null |
||||||
|
|
||||||
|
init { |
||||||
|
|
||||||
|
val realm = Realm.getDefaultInstance() |
||||||
|
|
||||||
|
this.sessions = realm.where(Session::class.java).findAll() |
||||||
|
this.sessions?.addChangeListener { _ -> |
||||||
|
requestReportLaunch() |
||||||
|
} |
||||||
|
|
||||||
|
this.results = realm.where(Result::class.java).findAll() |
||||||
|
this.results?.addChangeListener { _ -> |
||||||
|
requestReportLaunch() |
||||||
|
} |
||||||
|
|
||||||
|
realm.close() |
||||||
|
} |
||||||
|
|
||||||
|
fun addListener(newPerformanceListener: NewPerformanceListener) { |
||||||
|
this.listeners.add(newPerformanceListener) |
||||||
|
} |
||||||
|
|
||||||
|
fun removeListener(listener: NewPerformanceListener) { |
||||||
|
this.listeners.remove(listener) |
||||||
|
} |
||||||
|
|
||||||
|
fun requestReportLaunch() { |
||||||
|
// Timber.d(">>> Launch report") |
||||||
|
|
||||||
|
if (paused) { |
||||||
|
CrashLogging.log("can't start reports comparisons because of paused state") |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
this.timer?.cancel() |
||||||
|
|
||||||
|
val launchStart = 100L |
||||||
|
val timer = object: CountDownTimer(launchStart, launchStart) { |
||||||
|
override fun onTick(p0: Long) { } |
||||||
|
|
||||||
|
override fun onFinish() { |
||||||
|
launchReportTask() |
||||||
|
} |
||||||
|
} |
||||||
|
this.timer = timer |
||||||
|
timer.start() |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun launchReportTask() { |
||||||
|
|
||||||
|
synchronized(this) { |
||||||
|
this.currentTask?.cancel() |
||||||
|
|
||||||
|
val reportTask = ReportTask(this, this.context) |
||||||
|
this.currentTask = reportTask |
||||||
|
reportTask.start() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Pauses the whistleblower, for example when importing data |
||||||
|
*/ |
||||||
|
fun pause() { |
||||||
|
this.paused = true |
||||||
|
this.currentTask?.cancel() |
||||||
|
this.currentTask = null |
||||||
|
} |
||||||
|
|
||||||
|
fun resume() { |
||||||
|
this.paused = false |
||||||
|
this.requestReportLaunch() |
||||||
|
} |
||||||
|
|
||||||
|
fun has(performanceId: String): Boolean { |
||||||
|
return this.currentNotifications.contains(performanceId) |
||||||
|
} |
||||||
|
|
||||||
|
fun notify(performance: Performance) { |
||||||
|
|
||||||
|
this.currentNotifications.add(performance.id) |
||||||
|
for (listener in this.listeners) { |
||||||
|
listener.newBestPerformanceHandler() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun clearNotifications() { |
||||||
|
this.currentNotifications.clear() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
class ReportTask(private var whistleBlower: ReportWhistleBlower, var context: Context) { |
||||||
|
|
||||||
|
private var cancelled = false |
||||||
|
|
||||||
|
var handler: (() -> Unit)? = null |
||||||
|
|
||||||
|
private val coroutineContext: CoroutineContext |
||||||
|
get() = Dispatchers.Default |
||||||
|
|
||||||
|
fun start() { |
||||||
|
messages.add("Starting task...") |
||||||
|
launchReports() |
||||||
|
} |
||||||
|
|
||||||
|
fun cancel() { |
||||||
|
this.cancelled = true |
||||||
|
} |
||||||
|
|
||||||
|
var messages: MutableList<String> = mutableListOf() |
||||||
|
|
||||||
|
private fun launchReports() { |
||||||
|
CoroutineScope(coroutineContext).launch { |
||||||
|
|
||||||
|
val realm = Realm.getDefaultInstance() |
||||||
|
|
||||||
|
// Basic |
||||||
|
for (basicReport in StaticReport.basicReports) { |
||||||
|
if (cancelled) { |
||||||
|
break |
||||||
|
} |
||||||
|
launchReport(realm, basicReport) |
||||||
|
} |
||||||
|
|
||||||
|
// CustomField |
||||||
|
val customFields = realm.where(CustomField::class.java) |
||||||
|
.equalTo("type", CustomField.Type.LIST.uniqueIdentifier).findAll() |
||||||
|
for (customField in customFields) { |
||||||
|
if (cancelled) { |
||||||
|
break |
||||||
|
} |
||||||
|
launchReport(realm, StaticReport.CustomFieldList(customField)) |
||||||
|
} |
||||||
|
realm.close() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun launchReport(realm: Realm, report: StaticReport) { |
||||||
|
|
||||||
|
// Timber.d(">>> launch report = $report") |
||||||
|
|
||||||
|
when (report) { |
||||||
|
StaticReport.OptimalDuration -> launchOptimalDuration(realm, report) |
||||||
|
else -> launchDefaultReport(realm, report) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun launchDefaultReport(realm: Realm, report: StaticReport) { |
||||||
|
val options = Calculator.Options( |
||||||
|
stats = report.stats, |
||||||
|
criterias = report.criteria |
||||||
|
) |
||||||
|
|
||||||
|
val result = Calculator.computeStats(realm, options = options) |
||||||
|
analyseDefaultReport(realm, report, result) |
||||||
|
} |
||||||
|
|
||||||
|
private fun launchOptimalDuration(realm: Realm, report: StaticReport) { |
||||||
|
LiveOnline.entries.forEach { key -> |
||||||
|
val duration = CashGameOptimalDurationCalculator.start(key.isLive) |
||||||
|
analyseOptimalDuration(realm, report, key, duration) |
||||||
|
} |
||||||
|
|
||||||
|
this.handler?.let { it() } |
||||||
|
} |
||||||
|
|
||||||
|
private fun analyseDefaultReport(realm: Realm, staticReport: StaticReport, result: Report) { |
||||||
|
|
||||||
|
messages.add("Analyse report $staticReport...") |
||||||
|
|
||||||
|
val nameSeparator = " " |
||||||
|
|
||||||
|
for (stat in result.options.stats) { |
||||||
|
|
||||||
|
// Timber.d("analyse stat: $stat for report: $staticReport") |
||||||
|
|
||||||
|
// Get current performance |
||||||
|
var query = performancesQuery(realm, staticReport, stat) |
||||||
|
|
||||||
|
val customField: CustomField? = |
||||||
|
(staticReport as? StaticReport.CustomFieldList)?.customField |
||||||
|
customField?.let { |
||||||
|
query = query.equalTo("customFieldId", it.id) |
||||||
|
} |
||||||
|
val currentPerf = query.findFirst() |
||||||
|
|
||||||
|
// Store if necessary, delete if necessary |
||||||
|
val bestComputedResults = result.max(stat) |
||||||
|
bestComputedResults?.let { computedResults -> |
||||||
|
messages.add("found new perf...") |
||||||
|
|
||||||
|
val performanceQuery = computedResults.group.query |
||||||
|
val performanceName = performanceQuery.getName(this.context, nameSeparator) |
||||||
|
|
||||||
|
Timber.d("Best computed = $performanceName, ${computedResults.computedStat(Stat.NET_RESULT)?.value}") |
||||||
|
|
||||||
|
var storePerf = true |
||||||
|
currentPerf?.let { |
||||||
|
messages.add("has current perf...") |
||||||
|
|
||||||
|
currentPerf.name?.let { name -> |
||||||
|
if (computedResults.group.query.getName(this.context, nameSeparator) == name) { |
||||||
|
storePerf = false |
||||||
|
} |
||||||
|
} |
||||||
|
currentPerf.objectId?.let { objectId -> |
||||||
|
if (computedResults.group.query.objectId == objectId) { |
||||||
|
storePerf = false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (storePerf) { |
||||||
|
realm.executeTransaction { |
||||||
|
currentPerf.name = performanceName |
||||||
|
currentPerf.objectId = performanceQuery.objectId |
||||||
|
currentPerf.customFieldId = customField?.id |
||||||
|
} |
||||||
|
this.whistleBlower.notify(currentPerf) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
messages.add("storePerf = $storePerf...") |
||||||
|
|
||||||
|
if (currentPerf == null && storePerf) { |
||||||
|
val performance = Performance( |
||||||
|
staticReport, |
||||||
|
stat, |
||||||
|
performanceName, |
||||||
|
performanceQuery.objectId, |
||||||
|
customField?.id, |
||||||
|
null |
||||||
|
) |
||||||
|
realm.executeTransaction { it.copyToRealm(performance) } |
||||||
|
this.whistleBlower.notify(performance) |
||||||
|
} |
||||||
|
|
||||||
|
} ?: run { // if there is no max but a now irrelevant Performance, we delete it |
||||||
|
|
||||||
|
messages.add("deletes current perf if necessary: $currentPerf...") |
||||||
|
|
||||||
|
// Timber.d("NO best computed value, current perf = $currentPerf ") |
||||||
|
currentPerf?.let { perf -> |
||||||
|
realm.executeTransaction { |
||||||
|
// Timber.d("Delete perf: stat = ${perf.stat}, report = ${perf.reportId}") |
||||||
|
perf.deleteFromRealm() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun analyseOptimalDuration(realm: Realm, staticReport: StaticReport, key: PerformanceKey, duration: Double?) { |
||||||
|
|
||||||
|
val performance = performancesQuery(realm, staticReport, key).findFirst() |
||||||
|
|
||||||
|
duration?.let { |
||||||
|
var storePerf = true |
||||||
|
|
||||||
|
val formattedDuration = (duration / 3600 / 1000).formattedHourlyDuration() |
||||||
|
performance?.let { perf -> |
||||||
|
if (perf.value == duration) { |
||||||
|
storePerf = false |
||||||
|
} |
||||||
|
|
||||||
|
if (storePerf) { |
||||||
|
realm.executeTransaction { |
||||||
|
perf.name = formattedDuration |
||||||
|
perf.value = duration |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (storePerf) { |
||||||
|
val perf = Performance(staticReport, key, name = formattedDuration, value = duration) |
||||||
|
realm.executeTransaction { it.copyToRealm(perf) } |
||||||
|
this.whistleBlower.notify(perf) |
||||||
|
} |
||||||
|
|
||||||
|
} ?: run { // no duration |
||||||
|
performance?.let { perf -> |
||||||
|
realm.executeTransaction { |
||||||
|
perf.deleteFromRealm() // delete if the perf exists |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun performancesQuery(realm: Realm, staticReport: StaticReport, key: PerformanceKey): RealmQuery<Performance> { |
||||||
|
return realm.where(Performance::class.java) |
||||||
|
.equalTo("reportId", staticReport.uniqueIdentifier) |
||||||
|
.equalTo("key", key.value) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,4 +1,4 @@ |
|||||||
package net.pokeranalytics.android.calcul |
package net.pokeranalytics.android.calculus.calcul |
||||||
|
|
||||||
import net.pokeranalytics.android.calculus.AggregationType |
import net.pokeranalytics.android.calculus.AggregationType |
||||||
import net.pokeranalytics.android.ui.graph.Graph |
import net.pokeranalytics.android.ui.graph.Graph |
||||||
@ -1,4 +1,4 @@ |
|||||||
package net.pokeranalytics.android.calcul |
package net.pokeranalytics.android.calculus.calcul |
||||||
|
|
||||||
import android.content.Context |
import android.content.Context |
||||||
import com.github.mikephil.charting.data.* |
import com.github.mikephil.charting.data.* |
||||||
@ -1,4 +1,4 @@ |
|||||||
package net.pokeranalytics.android.calcul |
package net.pokeranalytics.android.calculus.calcul |
||||||
|
|
||||||
import net.pokeranalytics.android.R |
import net.pokeranalytics.android.R |
||||||
import net.pokeranalytics.android.calculus.Calculator |
import net.pokeranalytics.android.calculus.Calculator |
||||||
@ -1,4 +1,4 @@ |
|||||||
package net.pokeranalytics.android.calcul |
package net.pokeranalytics.android.calculus.calcul |
||||||
|
|
||||||
import android.content.Context |
import android.content.Context |
||||||
import com.github.mikephil.charting.data.BarDataSet |
import com.github.mikephil.charting.data.BarDataSet |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
package net.pokeranalytics.android.model |
||||||
|
|
||||||
|
import net.pokeranalytics.android.R |
||||||
|
import net.pokeranalytics.android.model.realm.PerformanceKey |
||||||
|
import net.pokeranalytics.android.util.enumerations.IntIdentifiable |
||||||
|
import net.pokeranalytics.android.util.enumerations.IntSearchable |
||||||
|
|
||||||
|
enum class LiveOnline(override var uniqueIdentifier: Int) : PerformanceKey, IntIdentifiable { |
||||||
|
LIVE(0), |
||||||
|
ONLINE(1); |
||||||
|
|
||||||
|
companion object : IntSearchable<LiveOnline> { |
||||||
|
|
||||||
|
override fun valuesInternal(): Array<LiveOnline> { |
||||||
|
return values() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
override val resId: Int? |
||||||
|
get() { |
||||||
|
return when (this) { |
||||||
|
LIVE -> R.string.live |
||||||
|
ONLINE -> R.string.online |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override val value: Int |
||||||
|
get() { |
||||||
|
return this.uniqueIdentifier |
||||||
|
} |
||||||
|
|
||||||
|
val isLive: Boolean |
||||||
|
get() { |
||||||
|
return (this == LIVE) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
package net.pokeranalytics.android.model |
||||||
|
|
||||||
|
data class Stakes(var blinds: String?, var ante: Double?) { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
package net.pokeranalytics.android.model.blogpost |
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName |
||||||
|
|
||||||
|
class BlogPost { |
||||||
|
|
||||||
|
@SerializedName("id") |
||||||
|
var id: Int = 0 |
||||||
|
|
||||||
|
@SerializedName("level") |
||||||
|
var content: String = "" |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,189 @@ |
|||||||
|
package net.pokeranalytics.android.model.interfaces |
||||||
|
|
||||||
|
import net.pokeranalytics.android.model.realm.Bankroll |
||||||
|
import net.pokeranalytics.android.util.BLIND_SEPARATOR |
||||||
|
import net.pokeranalytics.android.util.NULL_TEXT |
||||||
|
import net.pokeranalytics.android.util.UserDefaults |
||||||
|
import net.pokeranalytics.android.util.extensions.formatted |
||||||
|
import net.pokeranalytics.android.util.extensions.toCurrency |
||||||
|
import java.lang.Integer.min |
||||||
|
import java.text.NumberFormat |
||||||
|
import java.text.ParseException |
||||||
|
import java.util.* |
||||||
|
|
||||||
|
data class CodedStake(var stakes: String) : Comparable<CodedStake> { |
||||||
|
|
||||||
|
var ante: Double? = null |
||||||
|
var blinds: String? = null |
||||||
|
var currency: Currency |
||||||
|
|
||||||
|
init { |
||||||
|
|
||||||
|
var currencyCode: String? = null |
||||||
|
|
||||||
|
val parameters = this.stakes.split(StakesHolder.cbSeparator) |
||||||
|
parameters.forEach { param -> |
||||||
|
when { |
||||||
|
param.contains(StakesHolder.cbAnte) -> ante = param.removePrefix(StakesHolder.cbAnte).let { NumberFormat.getInstance().parse(it)?.toDouble() } |
||||||
|
param.contains(StakesHolder.cbBlinds) -> blinds = param.removePrefix(StakesHolder.cbBlinds) |
||||||
|
param.contains(StakesHolder.cbCode) -> currencyCode = param.removePrefix( |
||||||
|
StakesHolder.cbCode |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
this.currency = currencyCode?.let { Currency.getInstance(it) } |
||||||
|
?: run { UserDefaults.currency } |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
override fun compareTo(other: CodedStake): Int { |
||||||
|
|
||||||
|
if (this.currency == other.currency) { |
||||||
|
|
||||||
|
this.blinds?.let { b1 -> |
||||||
|
other.blinds?.let { b2 -> |
||||||
|
if (b1 == b2) { |
||||||
|
return this.compareAnte(other) |
||||||
|
} else { |
||||||
|
val bv1 = this.reversedBlindsArray(b1) |
||||||
|
val bv2 = this.reversedBlindsArray(b2) |
||||||
|
|
||||||
|
for (i in 0 until min(bv1.size, bv2.size)) { |
||||||
|
if (bv1[i] != bv2[i]) { |
||||||
|
return bv1[i].compareTo(bv2[i]) |
||||||
|
} else { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
return bv1.size.compareTo(bv2.size) |
||||||
|
} |
||||||
|
} ?: run { |
||||||
|
return 1 |
||||||
|
} |
||||||
|
|
||||||
|
} ?: run { |
||||||
|
return this.compareAnte(other) |
||||||
|
} |
||||||
|
} else { |
||||||
|
return this.currency.currencyCode.compareTo(other.currency.currencyCode) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun compareAnte(other: CodedStake): Int { |
||||||
|
this.ante?.let { a1 -> |
||||||
|
other.ante?.let { a2 -> |
||||||
|
return a1.compareTo(a2) |
||||||
|
} ?: run { |
||||||
|
return 1 |
||||||
|
} |
||||||
|
} ?: run { |
||||||
|
return -1 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun reversedBlindsArray(blinds: String): List<Double> { |
||||||
|
return blinds.split(BLIND_SEPARATOR).mapNotNull { NumberFormat.getInstance().parse(it)?.toDouble() }.reversed() |
||||||
|
} |
||||||
|
|
||||||
|
fun formattedStakes(): String { |
||||||
|
val components = arrayListOf<String>() |
||||||
|
this.formattedBlinds()?.let { components.add(it) } |
||||||
|
|
||||||
|
if ((this.ante ?: -1.0) > 0.0) { |
||||||
|
this.formattedAnte()?.let { components.add("($it)") } |
||||||
|
} |
||||||
|
|
||||||
|
return if (components.isNotEmpty()) { |
||||||
|
components.joinToString(" ") |
||||||
|
} else { |
||||||
|
NULL_TEXT |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun formattedBlinds(): String? { |
||||||
|
this.blinds?.let { |
||||||
|
val placeholder = 1.0 |
||||||
|
val regex = Regex("-?\\d+(\\.\\d+)?") |
||||||
|
return placeholder.toCurrency(currency).replace(regex, it) |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
private fun formattedAnte(): String? { |
||||||
|
this.ante?.let { |
||||||
|
return it.toCurrency(this.currency) |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
interface StakesHolder { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
const val cbSeparator = ";" |
||||||
|
const val cbAnte = "A=" |
||||||
|
const val cbBlinds = "B=" |
||||||
|
const val cbCode = "C=" |
||||||
|
|
||||||
|
fun readableStakes(value: String): String { |
||||||
|
return CodedStake(value).formattedStakes() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
val ante: Double? |
||||||
|
val blinds: String? |
||||||
|
val biggestBet: Double? |
||||||
|
val stakes: String? |
||||||
|
|
||||||
|
val bankroll: Bankroll? |
||||||
|
|
||||||
|
fun setHolderStakes(stakes: String?) |
||||||
|
fun setHolderBiggestBet(biggestBet: Double?) |
||||||
|
|
||||||
|
val blindValues: List<Double> |
||||||
|
get() { |
||||||
|
this.blinds?.let { blinds -> |
||||||
|
val blindsSplit = blinds.split(BLIND_SEPARATOR) |
||||||
|
return blindsSplit.mapNotNull { |
||||||
|
try { |
||||||
|
NumberFormat.getInstance().parse(it)?.toDouble() |
||||||
|
} catch (e: ParseException) { |
||||||
|
null |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return listOf() |
||||||
|
} |
||||||
|
|
||||||
|
fun generateStakes() { |
||||||
|
|
||||||
|
if (this.ante == null && this.blinds == null) { |
||||||
|
setHolderStakes(null) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
val components = arrayListOf<String>() |
||||||
|
|
||||||
|
this.blinds?.let { components.add("${cbBlinds}${it}") } |
||||||
|
this.ante?.let { components.add("${cbAnte}${it.formatted}") } |
||||||
|
|
||||||
|
val code = this.bankroll?.currency?.code ?: UserDefaults.currency.currencyCode |
||||||
|
components.add("${cbCode}${code}") |
||||||
|
|
||||||
|
setHolderStakes(components.joinToString(cbSeparator)) |
||||||
|
} |
||||||
|
|
||||||
|
fun defineHighestBet() { |
||||||
|
val bets = arrayListOf<Double>() |
||||||
|
this.ante?.let { bets.add(it) } |
||||||
|
bets.addAll(this.blindValues) |
||||||
|
setHolderBiggestBet(bets.maxOrNull()) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
package net.pokeranalytics.android.model.realm |
||||||
|
|
||||||
|
import io.realm.Realm |
||||||
|
import io.realm.RealmObject |
||||||
|
import io.realm.annotations.PrimaryKey |
||||||
|
import net.pokeranalytics.android.calculus.Stat |
||||||
|
import net.pokeranalytics.android.model.LiveOnline |
||||||
|
import net.pokeranalytics.android.ui.view.rows.StaticReport |
||||||
|
import net.pokeranalytics.android.util.NULL_TEXT |
||||||
|
import net.pokeranalytics.android.util.extensions.lookupForNameInAllTablesById |
||||||
|
import java.util.* |
||||||
|
|
||||||
|
|
||||||
|
interface PerformanceKey { |
||||||
|
val resId: Int? |
||||||
|
val value: Int |
||||||
|
} |
||||||
|
|
||||||
|
open class Performance() : RealmObject() { |
||||||
|
|
||||||
|
@PrimaryKey |
||||||
|
var id: String = UUID.randomUUID().toString() |
||||||
|
|
||||||
|
constructor( |
||||||
|
report: StaticReport, |
||||||
|
key: PerformanceKey, |
||||||
|
name: String? = null, |
||||||
|
objectId: String? = null, |
||||||
|
customFieldId: String? = null, |
||||||
|
value: Double? = null |
||||||
|
) : this() { |
||||||
|
|
||||||
|
this.reportId = report.uniqueIdentifier |
||||||
|
this.key = key.value |
||||||
|
this.name = name |
||||||
|
this.objectId = objectId |
||||||
|
this.customFieldId = customFieldId |
||||||
|
this.value = value |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
var reportId: Int = 0 |
||||||
|
var key: Int = 0 |
||||||
|
var name: String? = null |
||||||
|
var objectId: String? = null |
||||||
|
var customFieldId: String? = null |
||||||
|
var value: Double? = null |
||||||
|
|
||||||
|
fun toStaticReport(realm: Realm): StaticReport { |
||||||
|
return StaticReport.newInstance(realm, this.reportId, this.customFieldId) |
||||||
|
} |
||||||
|
|
||||||
|
fun displayValue(realm: Realm): CharSequence { |
||||||
|
this.name?.let { return it } |
||||||
|
this.objectId?.let { realm.lookupForNameInAllTablesById(it) } |
||||||
|
return NULL_TEXT |
||||||
|
} |
||||||
|
|
||||||
|
val stat: Stat |
||||||
|
get() { |
||||||
|
return Stat.valueByIdentifier(this.key.toInt()) |
||||||
|
} |
||||||
|
|
||||||
|
val resId: Int? |
||||||
|
get() { |
||||||
|
return when (this.reportId) { |
||||||
|
StaticReport.OptimalDuration.uniqueIdentifier -> LiveOnline.valueByIdentifier(this.key).resId |
||||||
|
else -> stat.resId |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
package net.pokeranalytics.android.model.realm |
||||||
|
|
||||||
|
import io.realm.Realm |
||||||
|
import io.realm.RealmObject |
||||||
|
import io.realm.annotations.PrimaryKey |
||||||
|
import net.pokeranalytics.android.util.UUID_SEPARATOR |
||||||
|
import net.pokeranalytics.android.util.extensions.findById |
||||||
|
import java.util.* |
||||||
|
|
||||||
|
open class UserConfig : RealmObject() { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
fun getConfiguration(realm: Realm): UserConfig { |
||||||
|
realm.where(UserConfig::class.java).findFirst()?.let { config -> |
||||||
|
return config |
||||||
|
} |
||||||
|
return UserConfig() |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@PrimaryKey |
||||||
|
var id = UUID.randomUUID().toString() |
||||||
|
|
||||||
|
var liveDealtHandsPerHour: Int = 250 |
||||||
|
|
||||||
|
var onlineDealtHandsPerHour: Int = 500 |
||||||
|
|
||||||
|
var transactionTypeIds: String = "" |
||||||
|
|
||||||
|
fun setTransactionTypeIds(transactionTypes: Set<TransactionType>) { |
||||||
|
this.transactionTypeIds = transactionTypes.joinToString(UUID_SEPARATOR) { it.id } |
||||||
|
} |
||||||
|
|
||||||
|
fun transactionTypes(realm: Realm): List<TransactionType> { |
||||||
|
val ids = this.transactionTypeIds.split(UUID_SEPARATOR) |
||||||
|
return ids.mapNotNull { realm.findById(it) } |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
package net.pokeranalytics.android.ui.activity |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.content.Context |
||||||
|
import android.content.Intent |
||||||
|
import android.net.Uri |
||||||
|
import android.os.Bundle |
||||||
|
import androidx.fragment.app.FragmentActivity |
||||||
|
import io.realm.Realm |
||||||
|
import net.pokeranalytics.android.R |
||||||
|
import net.pokeranalytics.android.exceptions.PAIllegalStateException |
||||||
|
import net.pokeranalytics.android.ui.activity.components.BaseActivity |
||||||
|
import net.pokeranalytics.android.ui.activity.components.RequestCode |
||||||
|
import net.pokeranalytics.android.ui.activity.components.ResultCode |
||||||
|
import net.pokeranalytics.android.ui.extensions.showAlertDialog |
||||||
|
import net.pokeranalytics.android.ui.extensions.toast |
||||||
|
import net.pokeranalytics.android.util.copyStreamToFile |
||||||
|
import timber.log.Timber |
||||||
|
import java.io.File |
||||||
|
|
||||||
|
class DatabaseCopyActivity : BaseActivity() { |
||||||
|
|
||||||
|
private lateinit var fileURI: Uri |
||||||
|
|
||||||
|
enum class IntentKey(val keyName: String) { |
||||||
|
URI("uri") |
||||||
|
} |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a new instance for result |
||||||
|
*/ |
||||||
|
fun newInstanceForResult(context: FragmentActivity, uri: Uri) { |
||||||
|
context.startActivityForResult(getIntent(context, uri), RequestCode.IMPORT.value) |
||||||
|
} |
||||||
|
|
||||||
|
private fun getIntent(context: Context, uri: Uri): Intent { |
||||||
|
Timber.d("getIntent") |
||||||
|
val intent = Intent(context, DatabaseCopyActivity::class.java) |
||||||
|
intent.putExtra(IntentKey.URI.keyName, uri) |
||||||
|
return intent |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||||
|
super.onCreate(savedInstanceState) |
||||||
|
|
||||||
|
Timber.d("onCreate") |
||||||
|
|
||||||
|
intent?.data?.let { |
||||||
|
this.fileURI = it |
||||||
|
} ?: run { |
||||||
|
this.fileURI = intent.getParcelableExtra(IntentKey.URI.keyName) ?: throw PAIllegalStateException("Uri not found") |
||||||
|
} |
||||||
|
|
||||||
|
// setContentView(R.layout.activity_import) |
||||||
|
requestImportConfirmation() |
||||||
|
} |
||||||
|
|
||||||
|
private fun initUI() { |
||||||
|
|
||||||
|
askForPermission(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), RequestCode.PERMISSION_WRITE_EXTERNAL_STORAGE.value) { |
||||||
|
val path = Realm.getDefaultInstance().path |
||||||
|
contentResolver.openInputStream(fileURI)?.let { inputStream -> |
||||||
|
val destination = File(path) |
||||||
|
inputStream.copyStreamToFile(destination) |
||||||
|
toast("Please restart app") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { |
||||||
|
super.onActivityResult(requestCode, resultCode, data) |
||||||
|
|
||||||
|
when (requestCode) { |
||||||
|
RequestCode.IMPORT.value -> { |
||||||
|
if (resultCode == ResultCode.IMPORT_UNRECOGNIZED_FORMAT.value) { |
||||||
|
showAlertDialog(context = this, messageResId = R.string.unknown_import_format_popup_message, positiveAction = { |
||||||
|
finish() |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Import |
||||||
|
|
||||||
|
private fun requestImportConfirmation() { |
||||||
|
|
||||||
|
showAlertDialog(context = this, title = R.string.import_confirmation, showCancelButton = true, positiveAction = { |
||||||
|
initUI() |
||||||
|
}, negativeAction = { |
||||||
|
finish() |
||||||
|
}) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,187 @@ |
|||||||
|
package net.pokeranalytics.android.ui.activity.components |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.app.Activity |
||||||
|
import android.content.ContentValues |
||||||
|
import android.content.Intent |
||||||
|
import android.content.pm.PackageManager |
||||||
|
import android.os.Build |
||||||
|
import android.os.Bundle |
||||||
|
import android.provider.MediaStore |
||||||
|
import android.widget.Toast |
||||||
|
import androidx.camera.core.CameraSelector |
||||||
|
import androidx.camera.core.ImageCapture |
||||||
|
import androidx.camera.core.ImageCaptureException |
||||||
|
import androidx.camera.core.Preview |
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider |
||||||
|
import androidx.core.app.ActivityCompat |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
import androidx.fragment.app.Fragment |
||||||
|
import net.pokeranalytics.android.databinding.ActivityCameraBinding |
||||||
|
import timber.log.Timber |
||||||
|
import java.text.SimpleDateFormat |
||||||
|
import java.util.* |
||||||
|
import java.util.concurrent.ExecutorService |
||||||
|
import java.util.concurrent.Executors |
||||||
|
|
||||||
|
class CameraActivity : BaseActivity() { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
const val IMAGE_URI = "image_uri" |
||||||
|
|
||||||
|
fun newInstanceForResult(fragment: Fragment, code: RequestCode) { |
||||||
|
val intent = Intent(fragment.requireContext(), CameraActivity::class.java) |
||||||
|
fragment.startActivityForResult(intent, code.value) |
||||||
|
} |
||||||
|
|
||||||
|
private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" |
||||||
|
private const val REQUEST_CODE_PERMISSIONS = 10 |
||||||
|
private val REQUIRED_PERMISSIONS = |
||||||
|
mutableListOf ( |
||||||
|
Manifest.permission.CAMERA |
||||||
|
).apply { |
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { |
||||||
|
add(Manifest.permission.WRITE_EXTERNAL_STORAGE) |
||||||
|
} |
||||||
|
}.toTypedArray() |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private lateinit var viewBinding: ActivityCameraBinding |
||||||
|
|
||||||
|
private lateinit var cameraExecutor: ExecutorService |
||||||
|
private var imageCapture: ImageCapture? = null |
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||||
|
super.onCreate(savedInstanceState) |
||||||
|
|
||||||
|
this.viewBinding = ActivityCameraBinding.inflate(layoutInflater) |
||||||
|
setContentView(viewBinding.root) |
||||||
|
|
||||||
|
if (allPermissionsGranted()) { |
||||||
|
cameraExecutor = Executors.newSingleThreadExecutor() |
||||||
|
startCamera() |
||||||
|
} else { |
||||||
|
ActivityCompat.requestPermissions( |
||||||
|
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS) |
||||||
|
} |
||||||
|
|
||||||
|
viewBinding.imageCaptureButton.setOnClickListener { takePhoto() } |
||||||
|
cameraExecutor = Executors.newSingleThreadExecutor() |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
override fun onDestroy() { |
||||||
|
super.onDestroy() |
||||||
|
this.cameraExecutor.shutdown() |
||||||
|
} |
||||||
|
|
||||||
|
override fun onRequestPermissionsResult( |
||||||
|
requestCode: Int, |
||||||
|
permissions: Array<out String>, |
||||||
|
grantResults: IntArray |
||||||
|
) { |
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults) |
||||||
|
|
||||||
|
if (requestCode == REQUEST_CODE_PERMISSIONS) { |
||||||
|
if (allPermissionsGranted()) { |
||||||
|
startCamera() |
||||||
|
} else { |
||||||
|
Toast.makeText(this, |
||||||
|
"Permissions not granted by the user.", |
||||||
|
Toast.LENGTH_SHORT).show() |
||||||
|
finish() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { |
||||||
|
ContextCompat.checkSelfPermission( |
||||||
|
baseContext, it) == PackageManager.PERMISSION_GRANTED |
||||||
|
} |
||||||
|
|
||||||
|
private fun startCamera() { |
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(this) |
||||||
|
|
||||||
|
cameraProviderFuture.addListener({ |
||||||
|
// Used to bind the lifecycle of cameras to the lifecycle owner |
||||||
|
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() |
||||||
|
|
||||||
|
// Preview |
||||||
|
val preview = Preview.Builder() |
||||||
|
.build() |
||||||
|
.also { |
||||||
|
it.setSurfaceProvider(this.viewBinding.viewFinder.surfaceProvider) |
||||||
|
} |
||||||
|
|
||||||
|
imageCapture = ImageCapture.Builder().build() |
||||||
|
|
||||||
|
// Select back camera as a default |
||||||
|
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA |
||||||
|
|
||||||
|
try { |
||||||
|
// Unbind use cases before rebinding |
||||||
|
cameraProvider.unbindAll() |
||||||
|
|
||||||
|
// Bind use cases to camera |
||||||
|
cameraProvider.bindToLifecycle( |
||||||
|
this, cameraSelector, preview, imageCapture) |
||||||
|
|
||||||
|
} catch(exc: Exception) { |
||||||
|
Timber.e(exc) |
||||||
|
} |
||||||
|
|
||||||
|
}, ContextCompat.getMainExecutor(this)) |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
private fun takePhoto() { |
||||||
|
// Get a stable reference of the modifiable image capture use case |
||||||
|
val imageCapture = imageCapture ?: return |
||||||
|
|
||||||
|
// Create time stamped name and MediaStore entry. |
||||||
|
val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US) |
||||||
|
.format(System.currentTimeMillis()) |
||||||
|
val contentValues = ContentValues().apply { |
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, name) |
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") |
||||||
|
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { |
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Create output options object which contains file + metadata |
||||||
|
val outputOptions = ImageCapture.OutputFileOptions |
||||||
|
.Builder(contentResolver, |
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, |
||||||
|
contentValues) |
||||||
|
.build() |
||||||
|
|
||||||
|
// Set up image capture listener, which is triggered after photo has |
||||||
|
// been taken |
||||||
|
imageCapture.takePicture( |
||||||
|
outputOptions, |
||||||
|
ContextCompat.getMainExecutor(this), |
||||||
|
object : ImageCapture.OnImageSavedCallback { |
||||||
|
override fun onError(e: ImageCaptureException) { |
||||||
|
Timber.e(e) |
||||||
|
} |
||||||
|
|
||||||
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) { |
||||||
|
|
||||||
|
// val msg = "Photo capture succeeded: ${output.savedUri}" |
||||||
|
// Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show() |
||||||
|
// Timber.d(msg) |
||||||
|
|
||||||
|
val intent = Intent() |
||||||
|
intent.putExtra(IMAGE_URI, output.savedUri.toString()) |
||||||
|
setResult(Activity.RESULT_OK, intent) |
||||||
|
|
||||||
|
finish() |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue