Compare commits
94 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 |
@ -1,56 +0,0 @@ |
|||||||
package net.pokeranalytics.android |
|
||||||
|
|
||||||
import android.app.Service |
|
||||||
import android.content.Intent |
|
||||||
import android.os.Binder |
|
||||||
import android.os.IBinder |
|
||||||
import io.realm.Realm |
|
||||||
import timber.log.Timber |
|
||||||
|
|
||||||
class RealmWriteService : Service() { |
|
||||||
|
|
||||||
private lateinit var realm: Realm |
|
||||||
|
|
||||||
private val binder = LocalBinder() |
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder { |
|
||||||
return binder |
|
||||||
} |
|
||||||
|
|
||||||
inner class LocalBinder : Binder() { |
|
||||||
fun getService(): RealmWriteService = this@RealmWriteService |
|
||||||
} |
|
||||||
|
|
||||||
override fun onCreate() { |
|
||||||
super.onCreate() |
|
||||||
this.realm = Realm.getDefaultInstance() |
|
||||||
} |
|
||||||
|
|
||||||
override fun onDestroy() { |
|
||||||
super.onDestroy() |
|
||||||
|
|
||||||
Timber.d(">>>> Service destroyed : realm close") |
|
||||||
this.realm.close() |
|
||||||
} |
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|
||||||
return super.onStartCommand(intent, flags, startId) |
|
||||||
} |
|
||||||
|
|
||||||
fun executeRealmAsyncTransaction(handler: (Realm) -> (Unit)) { |
|
||||||
|
|
||||||
Timber.d(">>>> Launch async transaction...") |
|
||||||
|
|
||||||
this.realm.executeTransactionAsync({ asyncRealm -> |
|
||||||
handler(asyncRealm) |
|
||||||
Timber.d(">> transaction handler done") |
|
||||||
}, { |
|
||||||
Timber.d(">> onSuccess, refreshing...") |
|
||||||
this.realm.refresh() |
|
||||||
}, { |
|
||||||
Timber.d(">> transaction failed: $it") |
|
||||||
}) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -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,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 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -1,61 +0,0 @@ |
|||||||
package net.pokeranalytics.android.model.realm |
|
||||||
|
|
||||||
import io.realm.RealmObject |
|
||||||
import io.realm.RealmResults |
|
||||||
import io.realm.annotations.LinkingObjects |
|
||||||
import io.realm.annotations.PrimaryKey |
|
||||||
import io.realm.annotations.RealmClass |
|
||||||
import net.pokeranalytics.android.model.filter.Filterable |
|
||||||
import net.pokeranalytics.android.model.filter.QueryCondition |
|
||||||
import java.util.* |
|
||||||
|
|
||||||
|
|
||||||
@RealmClass |
|
||||||
open class FlatTimeInterval : RealmObject(), Filterable { |
|
||||||
|
|
||||||
@PrimaryKey |
|
||||||
var id = UUID.randomUUID().toString() |
|
||||||
|
|
||||||
/** |
|
||||||
* The start date of the session |
|
||||||
*/ |
|
||||||
var startDate: Date = Date() |
|
||||||
set(value) { |
|
||||||
field = value |
|
||||||
this.computeDuration() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* The start date of the session |
|
||||||
*/ |
|
||||||
var endDate: Date = Date() |
|
||||||
set(value) { |
|
||||||
field = value |
|
||||||
this.computeDuration() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* the net duration of the session, automatically calculated |
|
||||||
*/ |
|
||||||
var duration: Long = 0L |
|
||||||
|
|
||||||
@LinkingObjects("flatTimeIntervals") |
|
||||||
val sessions: RealmResults<Session>? = null |
|
||||||
|
|
||||||
private fun computeDuration() { |
|
||||||
duration = endDate.time - startDate.time |
|
||||||
} |
|
||||||
|
|
||||||
companion object { |
|
||||||
|
|
||||||
fun fieldNameForQueryType(queryCondition: Class <out QueryCondition>): String? { |
|
||||||
Session.fieldNameForQueryType(queryCondition)?.let { |
|
||||||
return "sessions.$it" |
|
||||||
} |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@ -0,0 +1,288 @@ |
|||||||
|
//package net.pokeranalytics.android.model.realm |
||||||
|
// |
||||||
|
//import io.realm.RealmObject |
||||||
|
//import io.realm.RealmQuery |
||||||
|
//import io.realm.RealmResults |
||||||
|
//import io.realm.annotations.Ignore |
||||||
|
//import io.realm.annotations.LinkingObjects |
||||||
|
//import net.pokeranalytics.android.exceptions.ModelException |
||||||
|
//import timber.log.Timber |
||||||
|
//import java.util.* |
||||||
|
// |
||||||
|
//open class TimeFrame : RealmObject() { |
||||||
|
// |
||||||
|
// // A start date |
||||||
|
// var startDate: Date = Date() |
||||||
|
// private set(value) { |
||||||
|
// field = value |
||||||
|
// this.computeNetDuration() |
||||||
|
// } |
||||||
|
// |
||||||
|
// // An end date |
||||||
|
// var endDate: Date? = null |
||||||
|
// private set(value) { |
||||||
|
// field = value |
||||||
|
// this.computeNetDuration() |
||||||
|
// } |
||||||
|
// |
||||||
|
// // The latest pause date |
||||||
|
// var pauseDate: Date? = null |
||||||
|
// set(value) { |
||||||
|
// field?.let { |
||||||
|
// if (value == null && field != null) { |
||||||
|
// breakDuration += Date().time - it.time |
||||||
|
// } |
||||||
|
// } |
||||||
|
// field = value |
||||||
|
// this.computeNetDuration() |
||||||
|
// } |
||||||
|
// |
||||||
|
// // The break netDuration |
||||||
|
// var breakDuration: Long = 0L |
||||||
|
// set(value) { |
||||||
|
// field = value |
||||||
|
// this.computeNetDuration() |
||||||
|
// } |
||||||
|
// |
||||||
|
// // the total netDuration |
||||||
|
// var netDuration: Long = 0L |
||||||
|
// private set |
||||||
|
// |
||||||
|
// var hourlyDuration: Double = 0.0 |
||||||
|
// get() { |
||||||
|
// return this.netDuration / 3600000.0 // 3.6 millions of milliseconds |
||||||
|
// } |
||||||
|
// |
||||||
|
// // Session |
||||||
|
// @LinkingObjects("timeFrame") |
||||||
|
// private val endedSessions: RealmResults<Session>? = null // we should have only one session |
||||||
|
// |
||||||
|
// @Ignore |
||||||
|
// var session: Session? = null |
||||||
|
// get() = if (this.endedSessions != null && this.endedSessions.isEmpty()) null else this.endedSessions?.first() |
||||||
|
// |
||||||
|
// // Group |
||||||
|
// @LinkingObjects("timeFrame") |
||||||
|
// private val sets: RealmResults<SessionSet>? = null // we should have only one sessionGroup |
||||||
|
// |
||||||
|
// @Ignore |
||||||
|
// var set: SessionSet? = null |
||||||
|
// get() = this.sets?.first() |
||||||
|
// |
||||||
|
// fun setStart(startDate: Date) { |
||||||
|
// this.startDate = startDate |
||||||
|
// this.session?.let { |
||||||
|
// this.notifySessionDateChange(it) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
// fun setEnd(endDate: Date?) { |
||||||
|
// this.endDate = endDate |
||||||
|
// this.session?.let { |
||||||
|
// this.notifySessionDateChange(it) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
// fun setDate(startDate: Date, endDate: Date?) { |
||||||
|
// this.startDate = startDate |
||||||
|
// this.endDate = endDate |
||||||
|
// |
||||||
|
// this.session?.let { |
||||||
|
// this.notifySessionDateChange(it) |
||||||
|
// } |
||||||
|
// } |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Computes the net netDuration of the session |
||||||
|
// */ |
||||||
|
// private fun computeNetDuration() { |
||||||
|
// var endDate: Date = this.endDate ?: Date() |
||||||
|
// this.netDuration = endDate.time - this.startDate.time - this.breakDuration |
||||||
|
// } |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Queries all time frames that might be impacted by the date change |
||||||
|
// * Makes all necessary changes to keep sequential time frames |
||||||
|
// */ |
||||||
|
// fun notifySessionDateChange(owner: Session) { |
||||||
|
// |
||||||
|
// var query: RealmQuery<SessionSet> = this.realm.where(SessionSet::class.java) |
||||||
|
// query.isNotNull("timeFrame") |
||||||
|
// |
||||||
|
//// Timber.d("this> sd = : ${this.startDate}, ed = ${this.endDate}") |
||||||
|
// |
||||||
|
// val sets = realm.where(SessionSet::class.java).findAll() |
||||||
|
//// Timber.d("set count = ${sets.size}") |
||||||
|
// |
||||||
|
// if (this.endDate == null) { |
||||||
|
// query.greaterThanOrEqualTo("timeFrame.startDate", this.startDate) |
||||||
|
// .or() |
||||||
|
// .greaterThanOrEqualTo("timeFrame.endDate", this.startDate) |
||||||
|
// .or() |
||||||
|
// .isNull("timeFrame.endDate") |
||||||
|
// } else { |
||||||
|
// val endDate = this.endDate!! |
||||||
|
// query |
||||||
|
// .lessThanOrEqualTo("timeFrame.startDate", this.startDate) |
||||||
|
// .greaterThanOrEqualTo("timeFrame.endDate", this.startDate) |
||||||
|
// .or() |
||||||
|
// .lessThanOrEqualTo("timeFrame.startDate", endDate) |
||||||
|
// .greaterThanOrEqualTo("timeFrame.endDate", endDate) |
||||||
|
// .or() |
||||||
|
// .greaterThanOrEqualTo("timeFrame.startDate", this.startDate) |
||||||
|
// .lessThanOrEqualTo("timeFrame.endDate", endDate) |
||||||
|
// .or() |
||||||
|
// .isNull("timeFrame.endDate") |
||||||
|
// .lessThanOrEqualTo("timeFrame.startDate", endDate) |
||||||
|
// } |
||||||
|
// |
||||||
|
// val sessionGroups = query.findAll() |
||||||
|
// |
||||||
|
// this.updateTimeFrames(sessionGroups, owner) |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Update Time frames from sets |
||||||
|
// */ |
||||||
|
// private fun updateTimeFrames(sessionSets: RealmResults<SessionSet>, owner: Session) { |
||||||
|
// |
||||||
|
// when (sessionSets.size) { |
||||||
|
// 0 -> this.createOrUpdateSessionSet(owner) |
||||||
|
// 1 -> this.updateSessionGroup(owner, sessionSets.first()!!) |
||||||
|
// else -> this.mergeSessionGroups(owner, sessionSets) |
||||||
|
// } |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Creates the session sessionGroup when the session has none |
||||||
|
// */ |
||||||
|
// private fun createOrUpdateSessionSet(owner: Session) { |
||||||
|
// |
||||||
|
// val set = owner.sessionSet |
||||||
|
// if (set != null) { |
||||||
|
// set.timeFrame?.startDate = this.startDate |
||||||
|
// set.timeFrame?.endDate = this.endDate |
||||||
|
// } else { |
||||||
|
// this.createSessionSet(owner) |
||||||
|
// } |
||||||
|
// |
||||||
|
//// Timber.d("sd = : ${set.timeFrame?.startDate}, ed = ${set.timeFrame?.endDate}") |
||||||
|
// Timber.d("netDuration 1 = : ${set?.timeFrame?.netDuration}") |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// fun createSessionSet(owner: Session) { |
||||||
|
// val set: SessionSet = SessionSet.newInstanceForResult(this.realm) |
||||||
|
// set.timeFrame?.let { |
||||||
|
// it.startDate = this.startDate |
||||||
|
// it.endDate = this.endDate |
||||||
|
// } ?: run { |
||||||
|
// throw ModelException("TimeFrame should never be null here") |
||||||
|
// } |
||||||
|
// |
||||||
|
// owner.sessionSet = set |
||||||
|
// } |
||||||
|
// |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Single SessionSet update, the session might be the owner |
||||||
|
// * Changes the sessionGroup timeframe using the current timeframe dates |
||||||
|
// */ |
||||||
|
// private fun updateSessionGroup(owner: Session, sessionSet: SessionSet) { |
||||||
|
// |
||||||
|
// var timeFrame: TimeFrame = sessionSet.timeFrame!! // tested in the query |
||||||
|
//// timeFrame.setDate(this.startDate, this.endDate) |
||||||
|
// |
||||||
|
// val sisterSessions = sessionSet.endedSessions!! // shouldn't crash ever |
||||||
|
// |
||||||
|
// // if we have only one session in the set and that it corresponds to the set |
||||||
|
// if (sessionSet.endedSessions?.size == 1 && sessionSet.endedSessions?.first() == owner) { |
||||||
|
// timeFrame.setDate(this.startDate, this.endDate) |
||||||
|
// } else { // there are 2+ endedSessions to manage and possible splits |
||||||
|
// |
||||||
|
// val endDate = this.endDate |
||||||
|
// |
||||||
|
// // case where all endedSessions are over but the set is not, we might have a split, so we delete the set and save everything again |
||||||
|
// if (endDate != null && sisterSessions.all { it.timeFrame?.endDate != null } && timeFrame.endDate == null) { |
||||||
|
// var endedSessions = mutableListOf<Session>(owner) |
||||||
|
// sessionSet.endedSessions?.forEach { endedSessions.add(it) } |
||||||
|
// sessionSet.deleteFromRealm() |
||||||
|
// endedSessions.forEach { it.timeFrame?.notifySessionDateChange(it) } |
||||||
|
// } else { |
||||||
|
// |
||||||
|
// if (this.startDate.before(timeFrame.startDate)) { |
||||||
|
// timeFrame.startDate = this.startDate |
||||||
|
// } |
||||||
|
// if (endDate != null && timeFrame.endDate != null && endDate.after(timeFrame.endDate)) { |
||||||
|
// timeFrame.endDate = endDate |
||||||
|
// } else if (endDate == null) { |
||||||
|
// timeFrame.endDate = null |
||||||
|
// } |
||||||
|
// |
||||||
|
// owner.sessionSet = sessionSet |
||||||
|
// |
||||||
|
//// Timber.d("sd = : ${sessionSet.timeFrame?.startDate}, ed = ${sessionSet.timeFrame?.endDate}") |
||||||
|
// Timber.d("netDuration 2 = : ${sessionSet.timeFrame?.netDuration}") |
||||||
|
// } |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// /** |
||||||
|
// * Multiple session sets update: |
||||||
|
// * Merges all sets into one (delete all then create a new one) |
||||||
|
// */ |
||||||
|
// private fun mergeSessionGroups(owner: Session, sessionSets: RealmResults<SessionSet>) { |
||||||
|
// |
||||||
|
// var startDate: Date = this.startDate |
||||||
|
// var endDate: Date? = this.endDate |
||||||
|
// |
||||||
|
// // find earlier and later dates from all sets |
||||||
|
// val timeFrames = sessionSets.mapNotNull { it.timeFrame } |
||||||
|
// timeFrames.forEach { tf -> |
||||||
|
// if (tf.startDate.before(startDate)) { |
||||||
|
// startDate = tf.startDate |
||||||
|
// } |
||||||
|
// |
||||||
|
// endDate?.let { ed -> |
||||||
|
// tf.endDate?.let { tfed -> |
||||||
|
// if (tfed.after(ed)) { |
||||||
|
// endDate = tfed |
||||||
|
// } |
||||||
|
// } |
||||||
|
// } ?: run { |
||||||
|
// endDate = tf.endDate |
||||||
|
// } |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
// // get all endedSessions from sets |
||||||
|
// var endedSessions = mutableSetOf<Session>() |
||||||
|
// sessionSets.forEach { set -> |
||||||
|
// set.endedSessions?.asIterable()?.let { endedSessions.addAll(it) } |
||||||
|
// } |
||||||
|
// |
||||||
|
// // delete all sets |
||||||
|
// sessionSets.deleteAllFromRealm() |
||||||
|
// |
||||||
|
// // Create a new sets |
||||||
|
// val set: SessionSet = SessionSet.newInstanceForResult(this.realm) |
||||||
|
// set.timeFrame?.let { |
||||||
|
// it.setDate(startDate, endDate) |
||||||
|
// } ?: run { |
||||||
|
// throw ModelException("TimeFrame should never be null here") |
||||||
|
// } |
||||||
|
// |
||||||
|
// // Add the session linked to this timeframe to the new sessionGroup |
||||||
|
// owner.sessionSet = set |
||||||
|
// |
||||||
|
// // Add all orphan endedSessions |
||||||
|
// endedSessions.forEach { it.sessionSet = set } |
||||||
|
// Timber.d("netDuration 3 = : ${set.timeFrame?.netDuration}") |
||||||
|
// |
||||||
|
// } |
||||||
|
// |
||||||
|
//} |
||||||
@ -0,0 +1,209 @@ |
|||||||
|
package net.pokeranalytics.android.model.utils |
||||||
|
|
||||||
|
import io.realm.RealmQuery |
||||||
|
import io.realm.RealmResults |
||||||
|
import net.pokeranalytics.android.exceptions.ModelException |
||||||
|
import net.pokeranalytics.android.exceptions.PAIllegalStateException |
||||||
|
import net.pokeranalytics.android.model.realm.Session |
||||||
|
import net.pokeranalytics.android.model.realm.SessionSet |
||||||
|
import kotlin.math.max |
||||||
|
|
||||||
|
class CorruptSessionSetException(message: String) : Exception(message) |
||||||
|
|
||||||
|
/** |
||||||
|
* The manager is in charge of updating the abstract concept of timeline, |
||||||
|
* representing the sequenced time frames where the user plays. |
||||||
|
*/ |
||||||
|
class SessionSetManager { |
||||||
|
|
||||||
|
companion object { |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates the global timeline using the updated [session] |
||||||
|
*/ |
||||||
|
fun updateTimeline(session: Session) { |
||||||
|
|
||||||
|
if (!session.realm.isInTransaction) { |
||||||
|
throw PAIllegalStateException("realm should be in transaction at this point") |
||||||
|
} |
||||||
|
|
||||||
|
if (session.startDate == null) { |
||||||
|
throw ModelException("Start date should never be null here") |
||||||
|
} |
||||||
|
if (session.endDate == null) { |
||||||
|
throw ModelException("End date should never be null here") |
||||||
|
} |
||||||
|
|
||||||
|
val sessionSets = this.matchingSets(session) |
||||||
|
cleanupSessionSets(session, sessionSets) |
||||||
|
|
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private fun matchingSets(session: Session) : RealmResults<SessionSet> { |
||||||
|
val realm = session.realm |
||||||
|
val endDate = session.endDate!! // tested above |
||||||
|
val startDate = session.startDate!! |
||||||
|
|
||||||
|
val query: RealmQuery<SessionSet> = realm.where(SessionSet::class.java) |
||||||
|
|
||||||
|
query |
||||||
|
.lessThanOrEqualTo("startDate", startDate) |
||||||
|
.greaterThanOrEqualTo("endDate", startDate) |
||||||
|
.or() |
||||||
|
.lessThanOrEqualTo("startDate", endDate) |
||||||
|
.greaterThanOrEqualTo("endDate", endDate) |
||||||
|
.or() |
||||||
|
.greaterThanOrEqualTo("startDate", startDate) |
||||||
|
.lessThanOrEqualTo("endDate", endDate) |
||||||
|
|
||||||
|
return query.findAll() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Multiple session sets update: |
||||||
|
* Merges or splits session sets |
||||||
|
* Does that by deleting then recreating |
||||||
|
*/ |
||||||
|
private fun cleanupSessionSets(session: Session, sessionSets: RealmResults<SessionSet>) { |
||||||
|
|
||||||
|
// get all endedSessions from sets |
||||||
|
val allImpactedSessions = mutableSetOf<Session>() |
||||||
|
sessionSets.forEach { set -> |
||||||
|
set.sessions?.asIterable()?.let { allImpactedSessions.addAll(it) } |
||||||
|
} |
||||||
|
allImpactedSessions.add(session) |
||||||
|
|
||||||
|
// delete all sets |
||||||
|
sessionSets.deleteAllFromRealm() |
||||||
|
|
||||||
|
allImpactedSessions.forEach { impactedSession -> |
||||||
|
val sets = matchingSets(impactedSession) |
||||||
|
this.updateTimeFrames(sets, impactedSession) |
||||||
|
} |
||||||
|
|
||||||
|
// Timber.d("netDuration 3 = : ${set.timeFrame?.netDuration}") |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* Update the global timeline using the impacted [sessionSets] and the updated [session] |
||||||
|
*/ |
||||||
|
private fun updateTimeFrames(sessionSets: RealmResults<SessionSet>, session: Session) { |
||||||
|
|
||||||
|
when (sessionSets.size) { |
||||||
|
0 -> this.createOrUpdateSessionSet(session) |
||||||
|
else -> this.mergeSessionGroups(session, sessionSets) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates or update the session set for the [session] |
||||||
|
*/ |
||||||
|
private fun createOrUpdateSessionSet(session: Session) { |
||||||
|
|
||||||
|
val set = session.sessionSet |
||||||
|
if (set != null) { |
||||||
|
set.startDate = session.startDate!! // tested above |
||||||
|
set.endDate = session.endDate!! |
||||||
|
} else { |
||||||
|
this.createSessionSet(session) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a set and affect it to the [session] |
||||||
|
*/ |
||||||
|
private fun createSessionSet(session: Session) { |
||||||
|
val set: SessionSet = SessionSet.newInstance(session.realm) |
||||||
|
set.startDate = session.startDate!! |
||||||
|
set.endDate = session.endDate!! |
||||||
|
set.breakDuration = session.breakDuration |
||||||
|
session.sessionSet = set |
||||||
|
set.computeStats() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Multiple session sets update: |
||||||
|
* Merges all sets into one (delete all then create a new one) |
||||||
|
*/ |
||||||
|
private fun mergeSessionGroups(session: Session, sessionSets: RealmResults<SessionSet>) { |
||||||
|
|
||||||
|
var startDate = session.startDate!! |
||||||
|
var endDate = session.endDate!! |
||||||
|
|
||||||
|
// get all endedSessions from sets |
||||||
|
val sessions = mutableSetOf<Session>() |
||||||
|
sessionSets.forEach { set -> |
||||||
|
set.sessions?.asIterable()?.let { sessions.addAll(it) } |
||||||
|
} |
||||||
|
|
||||||
|
// find earlier and later dates from all sets |
||||||
|
sessions.forEach { s -> |
||||||
|
|
||||||
|
if (s.startDate != null && s.endDate != null) { |
||||||
|
val start = s.startDate!! |
||||||
|
val end = s.endDate!! |
||||||
|
if (start.before(startDate)) { |
||||||
|
startDate = start |
||||||
|
} |
||||||
|
if (end.after(endDate)) { |
||||||
|
endDate = end |
||||||
|
} |
||||||
|
} else { |
||||||
|
throw CorruptSessionSetException("Set contains unfinished sessions!") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// delete all sets |
||||||
|
sessionSets.deleteAllFromRealm() |
||||||
|
|
||||||
|
// Create a new set |
||||||
|
val set: SessionSet = SessionSet.newInstance(session.realm) |
||||||
|
set.startDate = startDate |
||||||
|
set.endDate = endDate |
||||||
|
|
||||||
|
// Add the session linked to this timeframe to the new sessionGroup |
||||||
|
session.sessionSet = set |
||||||
|
|
||||||
|
// Add all orphan endedSessions |
||||||
|
sessions.forEach { s -> |
||||||
|
s.sessionSet = set |
||||||
|
set.breakDuration = max(set.breakDuration, s.breakDuration) |
||||||
|
} |
||||||
|
set.computeStats() |
||||||
|
|
||||||
|
// Timber.d("netDuration 3 = : ${set.timeFrame?.netDuration}") |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes the [session] from the timeline |
||||||
|
*/ |
||||||
|
fun removeFromTimeline(session: Session) { |
||||||
|
|
||||||
|
if (!session.realm.isInTransaction) { |
||||||
|
throw PAIllegalStateException("realm should be in transaction at this point") |
||||||
|
} |
||||||
|
|
||||||
|
val sessionSet = session.sessionSet |
||||||
|
if (sessionSet != null) { |
||||||
|
|
||||||
|
val sessions = mutableSetOf<Session>() |
||||||
|
sessionSet.sessions?.asIterable()?.let { sessions.addAll(it) } |
||||||
|
sessions.remove(session) |
||||||
|
|
||||||
|
sessionSet.deleteFromRealm() |
||||||
|
|
||||||
|
sessions.forEach { |
||||||
|
updateTimeline(it) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,534 +0,0 @@ |
|||||||
package net.pokeranalytics.android.model.utils |
|
||||||
|
|
||||||
import io.realm.Realm |
|
||||||
import io.realm.RealmModel |
|
||||||
import io.realm.RealmQuery |
|
||||||
import io.realm.RealmResults |
|
||||||
import net.pokeranalytics.android.exceptions.ModelException |
|
||||||
import net.pokeranalytics.android.model.realm.FlatTimeInterval |
|
||||||
import net.pokeranalytics.android.model.realm.Session |
|
||||||
import net.pokeranalytics.android.model.realm.SessionSet |
|
||||||
import net.pokeranalytics.android.util.extensions.findById |
|
||||||
import net.pokeranalytics.android.util.extensions.max |
|
||||||
import net.pokeranalytics.android.util.extensions.min |
|
||||||
import timber.log.Timber |
|
||||||
import java.util.* |
|
||||||
|
|
||||||
class CorruptSessionSetException(message: String) : Exception(message) |
|
||||||
|
|
||||||
/** |
|
||||||
* The TimeManager pre-computes time related data: |
|
||||||
* - SessionSet: All overlapping sessions are grouped into a SessionSet, |
|
||||||
* used to calculate the number of sessions and break durations |
|
||||||
* - FlatTimeInterval: Sessions time intervals are breaked down into smaller intervals |
|
||||||
* when overlapping occurs to get faster duration calculations |
|
||||||
*/ |
|
||||||
object TimeManager { |
|
||||||
|
|
||||||
var sessions: RealmResults<Session>? = null |
|
||||||
|
|
||||||
private val sessionIdsToProcess = mutableSetOf<String>() |
|
||||||
|
|
||||||
private var start: Date? = null |
|
||||||
private var end: Date? = null |
|
||||||
|
|
||||||
fun configure() {} // launch init |
|
||||||
|
|
||||||
fun startChanged(session: Session, date: Date?) { |
|
||||||
this.start = min(this.start, date) |
|
||||||
this.end = max(this.end, session.endDate) |
|
||||||
this.sessionIdsToProcess.add(session.id) |
|
||||||
} |
|
||||||
|
|
||||||
fun endChanged(session: Session, date: Date?) { |
|
||||||
this.end = max(this.end, date) |
|
||||||
this.start = min(this.start, session.startDate) |
|
||||||
this.sessionIdsToProcess.add(session.id) |
|
||||||
} |
|
||||||
|
|
||||||
fun sessionDateChanged(session: Session) { |
|
||||||
this.start = min(this.start, session.startDate) |
|
||||||
this.end = max(this.end, session.endDate) |
|
||||||
this.sessionIdsToProcess.add(session.id) |
|
||||||
} |
|
||||||
|
|
||||||
init { |
|
||||||
|
|
||||||
val realm = Realm.getDefaultInstance() |
|
||||||
|
|
||||||
sessions = realm.where(Session::class.java).findAllAsync() |
|
||||||
sessions?.addChangeListener { _, _ -> |
|
||||||
|
|
||||||
if (sessionIdsToProcess.isNotEmpty()) { |
|
||||||
|
|
||||||
realm.executeTransactionAsync({ asyncRealm -> |
|
||||||
|
|
||||||
val sessions = sessionIdsToProcess.mapNotNull { asyncRealm.findById<Session>(it) } |
|
||||||
sessionIdsToProcess.clear() |
|
||||||
|
|
||||||
for (session in sessions) { |
|
||||||
Timber.d("Session id = ${session.id}") |
|
||||||
Timber.d("Session time intervals count = ${session.flatTimeIntervals.size}") |
|
||||||
session.flatTimeIntervals.deleteAllFromRealm() |
|
||||||
val fti = FlatTimeInterval() |
|
||||||
session.flatTimeIntervals.add(fti) |
|
||||||
asyncRealm.insertOrUpdate(session) |
|
||||||
} |
|
||||||
|
|
||||||
}, { |
|
||||||
Timber.d("executeTransactionAsync onSuccess listener...") |
|
||||||
val timeIntervals = realm.where(FlatTimeInterval::class.java).findAll() |
|
||||||
Timber.d("Total timeIntervals count = ${timeIntervals.size}") |
|
||||||
|
|
||||||
timeIntervals.forEach { |
|
||||||
Timber.d(">>> Time interval session count = ${it.sessions?.size}, session id = ${it.sessions?.firstOrNull()?.id}") |
|
||||||
} |
|
||||||
|
|
||||||
}, {}) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// sessions?.addChangeListener { _, _ -> |
|
||||||
// |
|
||||||
// Timber.d("...sessions change at ${Date().time}") |
|
||||||
// |
|
||||||
// val start = this.start |
|
||||||
// val end = this.end |
|
||||||
// if (start != null && end != null) { |
|
||||||
// |
|
||||||
// Timber.d("...process date changes from $start to $end") |
|
||||||
// |
|
||||||
// this.start = null |
|
||||||
// this.end = null |
|
||||||
// |
|
||||||
// realm.executeTransactionAsync ({ asyncRealm -> |
|
||||||
// processSessions(asyncRealm, start, end) |
|
||||||
// cleanUp() |
|
||||||
// }, { |
|
||||||
// Timber.d(">>>>> ON SUCCESS") |
|
||||||
// |
|
||||||
// realm.where(FlatTimeInterval::class.java).findAll().forEach { |
|
||||||
// Timber.d("######## sessions count = ${it.sessions?.size}") |
|
||||||
// } |
|
||||||
// |
|
||||||
// }, { |
|
||||||
// Timber.d("Transaction failed : $it") |
|
||||||
// }) |
|
||||||
// } |
|
||||||
// } |
|
||||||
|
|
||||||
realm.close() |
|
||||||
} |
|
||||||
|
|
||||||
private fun cleanUp() { |
|
||||||
this.start = null |
|
||||||
this.end = null |
|
||||||
this.sessionIdsToProcess.clear() |
|
||||||
} |
|
||||||
|
|
||||||
private fun processSessions(realm: Realm, start: Date, end: Date) { |
|
||||||
|
|
||||||
Timber.d("***** processSessions, process count = ${sessionIdsToProcess.size}") |
|
||||||
|
|
||||||
// val start = this.start |
|
||||||
// val end = this.end |
|
||||||
|
|
||||||
val sessions = sessionIdsToProcess.mapNotNull { realm.findById<Session>(it) } |
|
||||||
for (session in sessions) { |
|
||||||
|
|
||||||
// Session Sets |
|
||||||
val startDate = session.startDate |
|
||||||
val endDate = session.endDate |
|
||||||
if (startDate != null && endDate != null) { |
|
||||||
updateTimeline(session) |
|
||||||
} else if (session.sessionSet != null) { |
|
||||||
removeFromTimeline(session) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// FlatTimeIntervals |
|
||||||
processFlatTimeInterval(realm, sessions.toSet(), start, end) |
|
||||||
|
|
||||||
val ftis = realm.where(FlatTimeInterval::class.java).findAll() |
|
||||||
Timber.d("*** FTIs count = ${ftis.size}") |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Updates the global timeline using the updated [session] |
|
||||||
*/ |
|
||||||
fun updateTimeline(session: Session) { |
|
||||||
|
|
||||||
// if (!session.realm.isInTransaction) { |
|
||||||
// throw PAIllegalStateException("realm should be in transaction at this point") |
|
||||||
// } |
|
||||||
|
|
||||||
if (session.startDate == null) { |
|
||||||
throw ModelException("Start date should never be null here") |
|
||||||
} |
|
||||||
if (session.endDate == null) { |
|
||||||
throw ModelException("End date should never be null here") |
|
||||||
} |
|
||||||
|
|
||||||
val start = session.startDate!! |
|
||||||
val end = session.endDate!! |
|
||||||
|
|
||||||
val sessionSets = this.matchingData<SessionSet>(session.realm, start, end) |
|
||||||
cleanupSessionSets(session, sessionSets) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// private fun matchingSets(session: Session): RealmResults<SessionSet> { |
|
||||||
// val realm = session.realm |
|
||||||
// val endDate = session.endDate!! // tested above |
|
||||||
// val startDate = session.startDate!! |
|
||||||
// |
|
||||||
// val query: RealmQuery<SessionSet> = realm.where(SessionSet::class.java) |
|
||||||
// |
|
||||||
// query |
|
||||||
// .lessThanOrEqualTo("startDate", startDate) |
|
||||||
// .greaterThanOrEqualTo("endDate", startDate) |
|
||||||
// .or() |
|
||||||
// .lessThanOrEqualTo("startDate", endDate) |
|
||||||
// .greaterThanOrEqualTo("endDate", endDate) |
|
||||||
// .or() |
|
||||||
// .greaterThanOrEqualTo("startDate", startDate) |
|
||||||
// .lessThanOrEqualTo("endDate", endDate) |
|
||||||
// |
|
||||||
// return query.findAll() |
|
||||||
// } |
|
||||||
|
|
||||||
private inline fun <reified T : RealmModel> matchingData(realm: Realm, startDate: Date, endDate: Date): RealmResults<T> { |
|
||||||
|
|
||||||
val query: RealmQuery<T> = realm.where(T::class.java) |
|
||||||
|
|
||||||
query |
|
||||||
.lessThanOrEqualTo("startDate", startDate) |
|
||||||
.greaterThanOrEqualTo("endDate", startDate) |
|
||||||
.or() |
|
||||||
.lessThanOrEqualTo("startDate", endDate) |
|
||||||
.greaterThanOrEqualTo("endDate", endDate) |
|
||||||
.or() |
|
||||||
.greaterThanOrEqualTo("startDate", startDate) |
|
||||||
.lessThanOrEqualTo("endDate", endDate) |
|
||||||
|
|
||||||
return query.findAll() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Multiple session sets update: |
|
||||||
* Merges or splits session sets |
|
||||||
* Does that by deleting then recreating |
|
||||||
*/ |
|
||||||
private fun cleanupSessionSets(session: Session, sessionSets: RealmResults<SessionSet>) { |
|
||||||
|
|
||||||
// get all endedSessions from sets |
|
||||||
val allImpactedSessions = mutableSetOf<Session>() |
|
||||||
sessionSets.forEach { set -> |
|
||||||
set.sessions?.asIterable()?.let { allImpactedSessions.addAll(it) } |
|
||||||
} |
|
||||||
allImpactedSessions.add(session) |
|
||||||
|
|
||||||
// delete all sets |
|
||||||
sessionSets.deleteAllFromRealm() |
|
||||||
|
|
||||||
allImpactedSessions.forEach { impactedSession -> |
|
||||||
val sets = matchingData<SessionSet>(impactedSession.realm, impactedSession.startDate!!, impactedSession.endDate!!) |
|
||||||
this.updateTimeFrames(sets, impactedSession) |
|
||||||
} |
|
||||||
|
|
||||||
// Timber.d("netDuration 3 = : ${set.timeFrame?.netDuration}") |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Update the global timeline using the impacted [sessionSets] and the updated [session] |
|
||||||
*/ |
|
||||||
private fun updateTimeFrames(sessionSets: RealmResults<SessionSet>, session: Session) { |
|
||||||
when (sessionSets.size) { |
|
||||||
0 -> this.createOrUpdateSessionSet(session) |
|
||||||
else -> this.mergeSessionGroups(session, sessionSets) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Creates or update the session set for the [session] |
|
||||||
*/ |
|
||||||
private fun createOrUpdateSessionSet(session: Session) { |
|
||||||
|
|
||||||
val set = session.sessionSet |
|
||||||
if (set != null) { |
|
||||||
set.startDate = session.startDate!! // tested above |
|
||||||
set.endDate = session.endDate!! |
|
||||||
} else { |
|
||||||
this.createSessionSet(session) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Create a set and affect it to the [session] |
|
||||||
*/ |
|
||||||
private fun createSessionSet(session: Session) { |
|
||||||
val set = SessionSet.newInstance(session.realm) |
|
||||||
set.startDate = session.startDate!! |
|
||||||
set.endDate = session.endDate!! |
|
||||||
set.breakDuration = session.breakDuration |
|
||||||
session.sessionSet = set |
|
||||||
set.computeStats() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Multiple session sets update: |
|
||||||
* Merges all sets into one (delete all then create a new one) |
|
||||||
*/ |
|
||||||
private fun mergeSessionGroups(session: Session, sessionSets: RealmResults<SessionSet>) { |
|
||||||
|
|
||||||
var startDate = session.startDate!! |
|
||||||
var endDate = session.endDate!! |
|
||||||
|
|
||||||
// get all endedSessions from sets |
|
||||||
val sessions = mutableSetOf<Session>() |
|
||||||
sessionSets.forEach { set -> |
|
||||||
set.sessions?.asIterable()?.let { sessions.addAll(it) } |
|
||||||
} |
|
||||||
|
|
||||||
// find earlier and later dates from all sets |
|
||||||
sessions.forEach { s -> |
|
||||||
|
|
||||||
if (s.startDate != null && s.endDate != null) { |
|
||||||
val start = s.startDate!! |
|
||||||
val end = s.endDate!! |
|
||||||
if (start.before(startDate)) { |
|
||||||
startDate = start |
|
||||||
} |
|
||||||
if (end.after(endDate)) { |
|
||||||
endDate = end |
|
||||||
} |
|
||||||
} else { |
|
||||||
throw CorruptSessionSetException("Set contains unfinished sessions!") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// delete all sets |
|
||||||
sessionSets.deleteAllFromRealm() |
|
||||||
|
|
||||||
// Create a new set |
|
||||||
val set = SessionSet.newInstance(session.realm) |
|
||||||
set.startDate = startDate |
|
||||||
set.endDate = endDate |
|
||||||
|
|
||||||
// Add the session linked to this timeframe to the new sessionGroup |
|
||||||
session.sessionSet = set |
|
||||||
|
|
||||||
// Add all orphan endedSessions |
|
||||||
sessions.forEach { s -> |
|
||||||
s.sessionSet = set |
|
||||||
} |
|
||||||
set.computeStats() |
|
||||||
|
|
||||||
// Timber.d("netDuration 3 = : ${set.timeFrame?.netDuration}") |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Removes the [session] from the timeline |
|
||||||
*/ |
|
||||||
fun removeFromTimeline(session: Session) { |
|
||||||
|
|
||||||
// if (!session.realm.isInTransaction) { |
|
||||||
// throw PAIllegalStateException("realm should be in transaction at this point") |
|
||||||
// } |
|
||||||
|
|
||||||
val sessionSet = session.sessionSet |
|
||||||
if (sessionSet != null) { |
|
||||||
|
|
||||||
val sessions = mutableSetOf<Session>() |
|
||||||
sessionSet.sessions?.asIterable()?.let { sessions.addAll(it) } |
|
||||||
sessions.remove(session) |
|
||||||
|
|
||||||
sessionSet.deleteFromRealm() |
|
||||||
|
|
||||||
sessions.forEach { |
|
||||||
updateTimeline(it) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun processFlatTimeInterval(realm: Realm, changedSessions: Set<Session>, start: Date, end: Date) { |
|
||||||
|
|
||||||
// Timber.d("***************************************************") |
|
||||||
// Timber.d("*** processFlatTimeInterval, from: $start, to $end") |
|
||||||
// Timber.d("***************************************************") |
|
||||||
|
|
||||||
val sessions = matchingData<Session>(realm, start, end) |
|
||||||
val intervalsStore = IntervalsStore(sessions.toSet()) |
|
||||||
intervalsStore.processSessions(changedSessions) |
|
||||||
|
|
||||||
Timber.d("*** sessions count = ${intervalsStore.sessions.size}") |
|
||||||
Timber.d("*** ftis to delete: ${intervalsStore.intervals.size}") |
|
||||||
for (fti in intervalsStore.intervals) { |
|
||||||
fti.deleteFromRealm() |
|
||||||
} |
|
||||||
|
|
||||||
// intervalsStore.intervals.forEach { it.deleteFromRealm() } |
|
||||||
|
|
||||||
val intervals = SessionInterval.intervalMap(intervalsStore.sessions) |
|
||||||
|
|
||||||
for (interval in intervals) { |
|
||||||
|
|
||||||
val sortedDates = interval.dates.sorted() |
|
||||||
for (i in (0 until sortedDates.size - 1)) { |
|
||||||
|
|
||||||
val s = sortedDates[i] |
|
||||||
val e = sortedDates[i + 1] |
|
||||||
|
|
||||||
val matchingSessions = interval.sessions.filter { |
|
||||||
val sd = it.startDate |
|
||||||
val ed = it.endDate |
|
||||||
(sd != null && ed != null && sd <= s && ed >= e) |
|
||||||
} |
|
||||||
if (matchingSessions.isNotEmpty()) { |
|
||||||
// Timber.d("**** Create FTI: $s - $e") |
|
||||||
val fti = FlatTimeInterval() |
|
||||||
fti.startDate = s |
|
||||||
fti.endDate = e |
|
||||||
for (session in matchingSessions) { |
|
||||||
session.flatTimeIntervals.add(fti) |
|
||||||
realm.insertOrUpdate(session) |
|
||||||
} |
|
||||||
realm.insertOrUpdate(fti) |
|
||||||
} else { |
|
||||||
Timber.w("The FTI has no sessions") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
sessions.forEach { |
|
||||||
Timber.d("ending process...session FTI count = ${it.flatTimeIntervals.size}") |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
class IntervalsStore(sessionSet: Set<Session>) { |
|
||||||
|
|
||||||
var start: Date = Date() |
|
||||||
var end: Date = Date(0L) |
|
||||||
|
|
||||||
val intervals = mutableSetOf<FlatTimeInterval>() |
|
||||||
|
|
||||||
val sessions = mutableSetOf<Session>() |
|
||||||
|
|
||||||
private val sessionIds: MutableSet<String> = mutableSetOf() |
|
||||||
|
|
||||||
init { |
|
||||||
processSessions(sessionSet) |
|
||||||
} |
|
||||||
|
|
||||||
fun processSessions(sessions: Set<Session>) { |
|
||||||
this.sessions.addAll(sessions) |
|
||||||
for (session in sessions) { |
|
||||||
// Timber.d("PROCESS > s = ${session.startDate} / e = ${session.endDate} ") |
|
||||||
loadIntervals(session) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private fun loadIntervals(session: Session) { |
|
||||||
|
|
||||||
if (sessionIds.contains(session.id)) { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
session.startDate?.let { this.start = min(this.start, it) } |
|
||||||
session.endDate?.let { this.end = max(this.end, it) } |
|
||||||
|
|
||||||
this.sessionIds.add(session.id) |
|
||||||
|
|
||||||
Timber.d("session FTI count = ${session.flatTimeIntervals.size}") |
|
||||||
for (fti in session.flatTimeIntervals) { |
|
||||||
this.intervals.add(fti) |
|
||||||
|
|
||||||
fti.sessions?.let { sessions -> |
|
||||||
processSessions(sessions.toSet()) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
class SessionInterval(session: Session) { |
|
||||||
|
|
||||||
var start: Date |
|
||||||
var end: Date? |
|
||||||
|
|
||||||
var sessions: MutableSet<Session> = mutableSetOf() |
|
||||||
val dates: MutableSet<Date> = mutableSetOf() |
|
||||||
|
|
||||||
val duration: Long |
|
||||||
get() { |
|
||||||
val endDate = end ?: Date() |
|
||||||
return endDate.time - start.time |
|
||||||
} |
|
||||||
|
|
||||||
init { |
|
||||||
this.start = session.startDate!! |
|
||||||
this.end = session.endDate |
|
||||||
|
|
||||||
// Timber.d("INTERVAL init: s = $start, e = $end") |
|
||||||
|
|
||||||
this.addSession(session) |
|
||||||
} |
|
||||||
|
|
||||||
private fun addSession(session: Session) { |
|
||||||
this.sessions.add(session) |
|
||||||
|
|
||||||
session.startDate?.let { this.dates.add(it) } |
|
||||||
session.endDate?.let { endDate -> |
|
||||||
this.dates.add(endDate) |
|
||||||
if (endDate > end) { |
|
||||||
end = endDate |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
companion object { |
|
||||||
|
|
||||||
fun intervalMap(sessions: Set<Session>): List<SessionInterval> { |
|
||||||
|
|
||||||
val sorted = sessions.sortedBy { it.startDate } |
|
||||||
val intervals = mutableListOf<SessionInterval>() |
|
||||||
|
|
||||||
sorted.firstOrNull()?.let { firstSession -> |
|
||||||
|
|
||||||
var currentInterval = SessionInterval(firstSession) |
|
||||||
intervals.add(currentInterval) |
|
||||||
|
|
||||||
val remainingSessions = sorted.drop(1) |
|
||||||
for (session in remainingSessions) { |
|
||||||
val start = session.startDate!! |
|
||||||
val currentEnd = currentInterval.end |
|
||||||
if (currentEnd != null && start > currentEnd) { |
|
||||||
val interval = SessionInterval(session) |
|
||||||
currentInterval = interval |
|
||||||
intervals.add(interval) |
|
||||||
} else { |
|
||||||
currentInterval.addSession(session) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// intervals.forEach { |
|
||||||
// Timber.d("s = ${it.start}, e = ${it.end}") |
|
||||||
// } |
|
||||||
|
|
||||||
return intervals |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,153 @@ |
|||||||
|
package net.pokeranalytics.android.ui.modules.data |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import io.realm.Realm |
||||||
|
import io.realm.kotlin.where |
||||||
|
import net.pokeranalytics.android.model.realm.Comment |
||||||
|
import net.pokeranalytics.android.model.realm.Player |
||||||
|
import net.pokeranalytics.android.ui.adapter.StaticRowRepresentableDataSource |
||||||
|
import net.pokeranalytics.android.ui.view.RowRepresentable |
||||||
|
import net.pokeranalytics.android.ui.view.RowRepresentableEditDescriptor |
||||||
|
import net.pokeranalytics.android.ui.view.RowViewType |
||||||
|
import net.pokeranalytics.android.ui.view.rows.CustomizableRowRepresentable |
||||||
|
import net.pokeranalytics.android.ui.view.rows.PlayerPropertiesRow |
||||||
|
import net.pokeranalytics.android.ui.view.rows.SeparatorRow |
||||||
|
import net.pokeranalytics.android.ui.viewmodel.DataManagerViewModel |
||||||
|
import net.pokeranalytics.android.util.NULL_TEXT |
||||||
|
import net.pokeranalytics.android.util.extensions.isSameDay |
||||||
|
import net.pokeranalytics.android.util.extensions.mediumDate |
||||||
|
import java.util.* |
||||||
|
|
||||||
|
class PlayerDataViewModel : DataManagerViewModel(), StaticRowRepresentableDataSource { |
||||||
|
|
||||||
|
private var rowRepresentation: List<RowRepresentable> = mutableListOf() |
||||||
|
|
||||||
|
private var commentsToDelete: ArrayList<Comment> = ArrayList() |
||||||
|
|
||||||
|
var selectedTab: Int = 0 |
||||||
|
set(value) { |
||||||
|
field = value |
||||||
|
this.updateRowRepresentation() |
||||||
|
} |
||||||
|
|
||||||
|
private val player: Player |
||||||
|
get() { |
||||||
|
return this.item as Player |
||||||
|
} |
||||||
|
|
||||||
|
override fun adapterRows(): List<RowRepresentable> { |
||||||
|
return this.rowRepresentation |
||||||
|
} |
||||||
|
|
||||||
|
override fun charSequenceForRow( |
||||||
|
row: RowRepresentable, |
||||||
|
context: Context, |
||||||
|
tag: Int |
||||||
|
): CharSequence { |
||||||
|
return when (row) { |
||||||
|
PlayerPropertiesRow.NAME -> this.player.name.ifEmpty { NULL_TEXT } |
||||||
|
else -> return super.charSequenceForRow(row, context, 0) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun updateRowRepresentation() { |
||||||
|
this.rowRepresentation = this.updatedRowRepresentationForCurrentState() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update the row representation |
||||||
|
*/ |
||||||
|
private fun updatedRowRepresentationForCurrentState(): List<RowRepresentable> { |
||||||
|
val rows = ArrayList<RowRepresentable>() |
||||||
|
rows.add(PlayerPropertiesRow.IMAGE) |
||||||
|
rows.add(PlayerPropertiesRow.NAME) |
||||||
|
rows.add(PlayerPropertiesRow.SUMMARY) |
||||||
|
|
||||||
|
val realm = Realm.getDefaultInstance() |
||||||
|
val hands = this.player.hands(realm) |
||||||
|
realm.close() |
||||||
|
|
||||||
|
if (this.player.comments.isNotEmpty() || hands.isNotEmpty()) { |
||||||
|
rows.add(PlayerPropertiesRow.TAB_SELECTOR) |
||||||
|
} |
||||||
|
|
||||||
|
when(this.selectedTab) { |
||||||
|
0 -> this.addCommentSection(rows) |
||||||
|
1 -> rows.addAll(hands) |
||||||
|
} |
||||||
|
|
||||||
|
return rows |
||||||
|
} |
||||||
|
|
||||||
|
private fun addCommentSection(rows: ArrayList<RowRepresentable>) { |
||||||
|
|
||||||
|
if (this.player.comments.size > 0) { |
||||||
|
|
||||||
|
val currentCommentCalendar = Calendar.getInstance() |
||||||
|
val currentDateCalendar = Calendar.getInstance() |
||||||
|
|
||||||
|
val commentsToDisplay = ArrayList<Comment>() |
||||||
|
commentsToDisplay.addAll(this.player.comments) |
||||||
|
commentsToDisplay.sortByDescending { it.date } |
||||||
|
|
||||||
|
commentsToDisplay.forEachIndexed { index, comment -> |
||||||
|
currentCommentCalendar.time = comment.date |
||||||
|
|
||||||
|
if (!currentCommentCalendar.isSameDay(currentDateCalendar) || index == 0) { |
||||||
|
currentDateCalendar.time = currentCommentCalendar.time |
||||||
|
// Adds day sub section |
||||||
|
rows.add(CustomizableRowRepresentable(RowViewType.HEADER_SUBTITLE, title = currentDateCalendar.time.mediumDate())) |
||||||
|
} |
||||||
|
|
||||||
|
// Adds comment |
||||||
|
rows.add(comment) |
||||||
|
} |
||||||
|
rows.add(SeparatorRow()) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add an entry |
||||||
|
*/ |
||||||
|
fun addComment(): Comment { |
||||||
|
val entry = Comment() |
||||||
|
this.player.comments.add(entry) |
||||||
|
updateRowRepresentation() |
||||||
|
return entry |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete an entry |
||||||
|
*/ |
||||||
|
fun deleteComment(comment: Comment) { |
||||||
|
commentsToDelete.add(comment) |
||||||
|
this.player.comments.remove(comment) |
||||||
|
updateRowRepresentation() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clean up deleted entries |
||||||
|
*/ |
||||||
|
fun cleanupComments() { // called when saving the custom field |
||||||
|
val realm = Realm.getDefaultInstance() |
||||||
|
realm.executeTransaction { |
||||||
|
this.commentsToDelete.forEach { // entries are out of realm |
||||||
|
realm.where<Comment>().equalTo("id", it.id).findFirst()?.deleteFromRealm() |
||||||
|
} |
||||||
|
} |
||||||
|
realm.close() |
||||||
|
this.commentsToDelete.clear() |
||||||
|
} |
||||||
|
|
||||||
|
override fun editDescriptors(row: RowRepresentable): List<RowRepresentableEditDescriptor>? { |
||||||
|
|
||||||
|
when (row) { |
||||||
|
PlayerPropertiesRow.NAME -> return row.editingDescriptors(mapOf("defaultValue" to this.player.name)) |
||||||
|
PlayerPropertiesRow.SUMMARY -> return row.editingDescriptors(mapOf("defaultValue" to this.player.summary)) |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue