Commit 6fcf06a5 by Aleksandr Tamakov

Flow to send print jobs

parent 152ff13e
......@@ -107,6 +107,7 @@ dependencies {
implementation(project(":feature:rendering"))
implementation(project(":feature:job"))
implementation(project(":feature:spot"))
implementation(project(":feature:job_sender"))
// implementation(project((":feature:ms_office")))
implementation(project((":library:slider")))
implementation(project((":core:core")))
......@@ -125,9 +126,11 @@ dependencies {
implementation("androidx.preference:preference-ktx:${GoogleVersions.preferences}")
implementation("androidx.room:room-runtime:${GoogleVersions.room}")
implementation("androidx.hilt:hilt-work:${GoogleVersions.work_hilt}")
implementation("androidx.work:work-runtime-ktx:${GoogleVersions.work}")
ksp("com.google.dagger:hilt-compiler:${GoogleVersions.hilt}")
ksp("androidx.room:room-compiler:${GoogleVersions.room}")
ksp("androidx.hilt:hilt-compiler:${GoogleVersions.work_hilt}")
ksp("com.google.dagger:hilt-compiler:${GoogleVersions.hilt}")
// geo location
implementation(project(":library:location"))
......
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "bfd170d6f940593249fa8f99019bc713",
"entities": [
{
"tableName": "Session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "PrintProfile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `spotId` TEXT NOT NULL, `name` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `printWidth` INTEGER NOT NULL, `printHeight` INTEGER NOT NULL, `marginTop` INTEGER NOT NULL, `marginLeft` INTEGER NOT NULL, `dpix` INTEGER NOT NULL, `dpiy` INTEGER NOT NULL, `cost` REAL NOT NULL, `sort` INTEGER NOT NULL, `grayscale` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spotId",
"columnName": "spotId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "width",
"columnName": "width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "height",
"columnName": "height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "printWidth",
"columnName": "printWidth",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "printHeight",
"columnName": "printHeight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "marginTop",
"columnName": "marginTop",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "marginLeft",
"columnName": "marginLeft",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dpix",
"columnName": "dpix",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dpiy",
"columnName": "dpiy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cost",
"columnName": "cost",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "grayscale",
"columnName": "grayscale",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Spot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `code` TEXT NOT NULL, `address` TEXT NOT NULL, `status` TEXT, `distance` REAL NOT NULL, `lng` REAL NOT NULL, `lat` REAL NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "code",
"columnName": "code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "address",
"columnName": "address",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "distance",
"columnName": "distance",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lng",
"columnName": "lng",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lat",
"columnName": "lat",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Spot_code",
"unique": false,
"columnNames": [
"code"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Spot_code` ON `${TABLE_NAME}` (`code`)"
}
],
"foreignKeys": []
},
{
"tableName": "PrintJob",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `spotId` TEXT NOT NULL, `cost` REAL NOT NULL, `profileId` TEXT NOT NULL, `printSize` INTEGER NOT NULL, `printOrientation` INTEGER NOT NULL, `copies` INTEGER NOT NULL, `status` INTEGER NOT NULL, `name` TEXT NOT NULL, `errorMessage` TEXT, `comment` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `accessCode` TEXT, `accessToken` TEXT, `sourceFiles` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "spotId",
"columnName": "spotId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cost",
"columnName": "cost",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "printSize",
"columnName": "printSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "printOrientation",
"columnName": "printOrientation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "copies",
"columnName": "copies",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessCode",
"columnName": "accessCode",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sourceFiles",
"columnName": "sourceFiles",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_PrintJob_spotId",
"unique": false,
"columnNames": [
"spotId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_PrintJob_spotId` ON `${TABLE_NAME}` (`spotId`)"
},
{
"name": "index_PrintJob_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_PrintJob_status` ON `${TABLE_NAME}` (`status`)"
}
],
"foreignKeys": []
},
{
"tableName": "PrintJobSender",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `printJobId` TEXT NOT NULL, `status` INTEGER NOT NULL, `sourceFile` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "printJobId",
"columnName": "printJobId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sourceFile",
"columnName": "sourceFile",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bfd170d6f940593249fa8f99019bc713')"
]
}
}
\ No newline at end of file
......@@ -48,6 +48,18 @@
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyBKI2W1CALiYHJcu8KNfYbhTZBpOZZKYPc" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>
\ No newline at end of file
......@@ -19,13 +19,11 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.isidroid.c23.data.worker.job.SendJobWorker
import com.isidroid.c23.ext.isEdgeToEdge
import com.isidroid.c23.ui.navigation.AppNavHost
import com.isidroid.c23.ui.theme.AppTheme
import com.isidroid.core.ext.printCurrentDestination
import dagger.hilt.android.AndroidEntryPoint
import java.util.UUID
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
......
......@@ -7,7 +7,9 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.isidroid.job.data.source.local.JobDao
import com.isidroid.job_sender.data.source.local.SendJobDao
import com.isidroid.job.model.PrintJob
import com.isidroid.job_sender.domain.model.PrintJobSender
import com.isidroid.spot.data.source.local.dao.PrintProfileDao
import com.isidroid.spot.model.PrintProfile
import com.isidroid.session.data.source.local.SessionDao
......@@ -16,16 +18,18 @@ import com.isidroid.spot.model.Spot
import com.isidroid.spot.data.source.local.dao.SpotDao
@Database(
version = 2,
version = 3,
entities = [
Session::class,
PrintProfile::class,
Spot::class,
PrintJob::class
PrintJob::class,
PrintJobSender::class
],
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
]
)
@TypeConverters(RoomConverters::class)
......@@ -34,6 +38,7 @@ abstract class AppDataBase : RoomDatabase() {
abstract val printProfileDao: PrintProfileDao
abstract val spotDao: SpotDao
abstract val jobDao: JobDao
abstract val sendJobDao: SendJobDao
companion object {
@Volatile
......
package com.isidroid.c23.data.worker.job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class SendJobEventCollectorFlow @Inject constructor() {
private val _eventsFlow = MutableSharedFlow<State>()
val eventsFlow = _eventsFlow.asSharedFlow()
class State {
}
}
\ No newline at end of file
......@@ -23,4 +23,9 @@ object DatabaseModule {
@Provides @Singleton
fun providesJobDao(appDataBase: AppDataBase) = appDataBase.jobDao
@Provides @Singleton
fun providesSendJobDao(appDataBase: AppDataBase) = appDataBase.sendJobDao
}
\ No newline at end of file
package com.isidroid.c23.domain.use_case
import android.content.Context
import com.isidroid.job_sender.SendJobWorker
import com.isidroid.c23.ext.isDebug
import com.isidroid.c23.ui.screen.home.HomeContract
import com.isidroid.core.FlowResult
import com.isidroid.session.repository.SessionRepository
import com.isidroid.spot.repository.ActiveSpotRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
......@@ -12,12 +15,15 @@ import javax.inject.Singleton
@Singleton
class HomeUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val sessionRepository: SessionRepository,
private val activeSpotRepository: ActiveSpotRepository
) {
fun createSession() = flow {
emit(FlowResult.Loading)
SendJobWorker.create(context)
val maxDelay = if (isDebug()) 0 else 3_000
val startedAt = System.currentTimeMillis()
......
......@@ -20,7 +20,7 @@ class PrintJobsUseCase @Inject constructor(
fun load() = flow {
emit(FlowResult.Loading)
val jobList = repository.readLocalList()
val spots = jobList.map { it.spotId }.distinct().let { spotRepository.finLocalRichSpots(it) }?.associateBy({ it.spot.id }, { it })
val spots = jobList.map { it.spotId }.distinct().let { spotRepository.findLocalRichSpots(it) }?.associateBy({ it.spot.id }, { it })
val result = jobList.map { job ->
val richSpot = spots?.get(job.spotId)
......
......@@ -6,7 +6,7 @@ import android.net.Uri
import androidx.compose.ui.unit.IntSize
import com.isidroid.c23.R
import com.isidroid.c23.SpotHasNoPrintProfilesException
import com.isidroid.c23.data.worker.job.SendJobWorker
import com.isidroid.job_sender.SendJobWorker
import com.isidroid.c23.ext.transformToBitmapFiles
import com.isidroid.core.FlowResult
import com.isidroid.job.repository.JobRepository
......
......@@ -51,7 +51,7 @@ object NetworkVersions {
object FirebaseDependencies {
const val analytics = "com.google.firebase:firebase-analytics-ktx"
const val bom = "33.1.0"
const val bom = "33.1.1"
const val crashlytics = "com.google.firebase:firebase-crashlytics-ktx"
const val messaging = "com.google.firebase:firebase-messaging-ktx"
const val config = "com.google.firebase:firebase-config-ktx"
......@@ -69,8 +69,8 @@ object ToolsVersions {
object TestVersions {
const val junit = "4.13.2"
const val junitExt = "1.1.5"
const val espressoCore = "3.5.1"
const val junitExt = "1.2.1"
const val espressoCore = "3.6.1"
const val mockitoCore = "5.7.0"
const val mockitoKotlin = "5.1.0"
const val mockWebServer = "4.12.0"
......
package com.isidroid.network
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import timber.log.Timber
import java.io.File
class ProgressEmittingRequestBody(
private val mediaType: String,
private val file: File,
) : RequestBody() {
override fun contentType(): MediaType? = mediaType.toMediaTypeOrNull()
override fun contentLength(): Long = file.length()
override fun writeTo(sink: BufferedSink) {
val buffer = ByteArray(1024)
var uploaded: Long = 0
val inputStream = file.inputStream()
val fileSize = file.length()
try {
while (true) {
val read = inputStream.read(buffer)
if (read == -1) break
uploaded += read
sink.write(buffer, 0, read)
val progress = (((uploaded / fileSize.toDouble())) * 100).toInt()
}
} catch (e: Exception) {
Timber.e(e)
} finally {
inputStream.close()
}
}
}
\ No newline at end of file
......@@ -49,7 +49,6 @@ dependencies {
ksp("androidx.room:room-compiler:${GoogleVersions.room}")
// Firebase
api(platform("com.google.firebase:firebase-bom:${FirebaseDependencies.bom}"))
api("com.google.firebase:firebase-crashlytics-ktx")
// tools
......
......@@ -14,6 +14,10 @@ annotation class JobStatus {
const val PRINTING = 6
const val COMPLETED = 7
const val ERROR = 8
const val IDLE = 0
const val RENDER_UPLOAD = 9
const val READY_TO_PRINT = 10
}
}
......@@ -28,5 +32,9 @@ val Int.statusName
JobStatus.PRINTING -> "PRINTING"
JobStatus.COMPLETED -> "COMPLETED"
JobStatus.ERROR -> "ERROR"
JobStatus.IDLE -> "IDLE"
JobStatus.RENDER_UPLOAD -> "RENDER_UPLOAD"
JobStatus.READY_TO_PRINT -> "READY_TO_PRINT"
else -> "Unknown"
}
\ No newline at end of file
package com.isidroid.job.constant
@Retention(AnnotationRetention.SOURCE)
annotation class SendJobStatus {
companion object {
const val RENDERED = 0
const val SENDING = 1
}
}
......@@ -16,9 +16,17 @@ interface JobDao {
@Delete
fun delete(vararg printJob: PrintJob)
@Query("SELECT * FROM PrintJob WHERE (:spotId IS NULL OR spotId = :spotId) OR (status IS NULL OR status = :status) ORDER BY createdAt DESC")
@Query("SELECT * FROM PrintJob " +
"WHERE (:spotId IS NULL OR spotId = :spotId) " +
"AND (:status IS NULL OR status = :status) " +
"AND (:ids IS NULL OR id IN (:ids))" +
"ORDER BY createdAt DESC")
fun find(
spotId: String? = null,
@JobStatus status: Int? = null
@JobStatus status: Int? = null,
ids: List<String>? = null
): List<PrintJob>
@Query("UPDATE PrintJob SET status = :status WHERE id IN (:ids)")
fun updateJobStatus(status: Int, vararg ids: String)
}
\ No newline at end of file
......@@ -2,8 +2,9 @@ package com.isidroid.job.data.source.local
import com.isidroid.job.model.PrintJob
internal class JobLocalSource(private val dao: JobDao) : JobDao {
class JobLocalSource(private val dao: JobDao) : JobDao {
override fun insert(vararg printJob: PrintJob) = dao.insert(*printJob)
override fun delete(vararg printJob: PrintJob) = dao.delete(*printJob)
override fun find(spotId: String?, status: Int?) = dao.find(spotId, status)
override fun find(spotId: String?, status: Int?, ids: List<String>?) = dao.find(spotId, status, ids)
override fun updateJobStatus(status: Int, vararg ids: String) = dao.updateJobStatus(status, *ids)
}
\ No newline at end of file
......@@ -4,10 +4,28 @@ import com.isidroid.job.data.mapper.transform
import com.isidroid.job.data.source.remote.api.ApiJob
import com.isidroid.job.data.source.remote.api.request.CreateJobRequest
import com.isidroid.job.model.PrintJob
import com.isidroid.network.ProgressEmittingRequestBody
import okhttp3.MultipartBody
import java.io.File
internal class JobNetworkSource(private val api: ApiJob) {
class JobNetworkSource(private val api: ApiJob) {
fun createJob(spotId: String, profileId: String, clientName: String): PrintJob? {
val response = api.createJob(CreateJobRequest(spotId, profileId, senderName = clientName)).execute()
return response.body()?.transform()
}
fun uploadPage(jobId: String, token: String, filePath: String): Boolean {
val file = File(filePath)
val requestBody = ProgressEmittingRequestBody("image/*", file)
val fileBody = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = api.uploadPage(jobId = jobId, token = token, fileBody).execute()
return response.isSuccessful
}
fun complete(jobId: String, token: String): Boolean {
api.complete(jobId = jobId, token = token).execute()
return true
}
}
\ No newline at end of file
......@@ -2,11 +2,33 @@ package com.isidroid.job.data.source.remote.api
import com.isidroid.job.data.source.remote.api.request.CreateJobRequest
import com.isidroid.job.data.source.remote.api.response.CreateJobResponse
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
internal interface ApiJob {
interface ApiJob {
@POST("api/job")
fun createJob(@Body request: CreateJobRequest): Call<CreateJobResponse>
@Multipart
@POST("api/job/{jobId}")
fun uploadPage(
@Path("jobId") jobId: String,
@Header("X-Access-Token") token: String,
@Part body: MultipartBody.Part
): Call<ResponseBody>
@PATCH("api/job/{jobId}")
fun complete(
@Path("jobId") jobId: String,
@Header("X-Access-Token") token: String,
): Call<ResponseBody>
}
\ No newline at end of file
......@@ -2,7 +2,7 @@ package com.isidroid.job.data.source.remote.api.request
import com.google.gson.annotations.SerializedName
internal data class CreateJobRequest(
data class CreateJobRequest(
@SerializedName("spot_id") val spotId: String,
@SerializedName("profile_id") val profileId: String,
@SerializedName("sender_name") val senderName: String,
......
......@@ -4,7 +4,7 @@ import com.google.gson.annotations.SerializedName
import java.io.Serial
import java.util.Date
internal data class CreateJobResponse(
data class CreateJobResponse(
@SerializedName("id") val id: String,
@SerializedName("access_code") val accessCode: String,
@SerializedName("access_token") val accessToken: String,
......
......@@ -4,8 +4,8 @@ import android.content.Context
import com.google.gson.Gson
import com.isidroid.core.DiDebuggableBuild
import com.isidroid.core.DiMock
import com.isidroid.job.data.source.local.JobDao
import com.isidroid.job.data.source.local.JobLocalSource
import com.isidroid.job.data.source.local.JobDao
import com.isidroid.job.data.source.remote.JobNetworkSource
import com.isidroid.job.data.source.remote.MockInterceptor
import com.isidroid.job.data.source.remote.api.ApiJob
......@@ -35,12 +35,13 @@ internal object JobModule {
fun provideJobLocalSource(dao: JobDao) = JobLocalSource(dao)
@Provides @Singleton
fun provideJobRepository(
@ApplicationContext context: Context,
jobNetworkSource: JobNetworkSource,
jobLocalSource: JobLocalSource,
): JobRepository = JobRepositoryImpl(context, jobNetworkSource, jobLocalSource)
): JobRepository = JobRepositoryImpl(jobNetworkSource, jobLocalSource)
@Provides @DiJobMockInterceptor
fun provideMockInterceptor(
......
......@@ -10,13 +10,13 @@ import java.util.UUID
@Entity(indices = [Index("spotId"), Index("status")])
data class PrintJob(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val spotId: String,
val spotId: String = "",
val cost: Float = 0f,
val profileId: String,
val profileId: String = "",
val printSize: Int = -1,
val printOrientation: Int = -1,
val copies: Int = 1,
@JobStatus val status: Int,
@JobStatus val status: Int = JobStatus.IDLE,
val name: String = "",
val errorMessage: String? = null,
val comment: String? = null,
......
package com.isidroid.job.repository
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.model.PrintJob
import java.io.File
......@@ -15,5 +16,8 @@ interface JobRepository {
sourceFiles: List<File>
): PrintJob
suspend fun readLocalList(): List<PrintJob>
suspend fun readLocalList(spotId: String? = null, @JobStatus status: Int? = null, ids: List<String>? = null): Collection<PrintJob>
suspend fun updateJobStatus(@JobStatus status: Int, vararg ids: String)
suspend fun uploadPage(jobId: String, token: String, filePath: String): Boolean
suspend fun completeUpload(jobId: String, token: String): PrintJob?
}
\ No newline at end of file
package com.isidroid.job.repository
import android.content.Context
import com.isidroid.job.CreateJobException
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.data.source.local.JobLocalSource
import com.isidroid.job.data.source.remote.JobNetworkSource
import com.isidroid.job.model.PrintJob
import java.io.File
internal class JobRepositoryImpl(
private val context: Context,
internal class JobRepositoryImpl(
private val jobNetworkSource: JobNetworkSource,
private val jobLocalSource: JobLocalSource,
) : JobRepository {
......@@ -31,8 +30,13 @@ internal class JobRepositoryImpl(
return job
}
override suspend fun readLocalList(): List<PrintJob> {
return jobLocalSource.find()
}
override suspend fun readLocalList(spotId: String?, status: Int?, ids: List<String>?) = jobLocalSource.find(spotId = spotId, status = status, ids = ids)
override suspend fun updateJobStatus(status: Int, vararg ids: String) = jobLocalSource.updateJobStatus(status, *ids)
override suspend fun uploadPage(jobId: String, token: String, filePath: String) = jobNetworkSource.uploadPage(jobId = jobId, token = token, filePath = filePath)
override suspend fun completeUpload(jobId: String, token: String): PrintJob? {
val printJob = jobLocalSource.find(ids = listOf(jobId)).firstOrNull()
val uploadResult = jobNetworkSource.complete(jobId = jobId, token = token)
return printJob?.copy(status = JobStatus.READY_TO_PRINT)?.also { jobLocalSource.insert(it) }?.takeIf { uploadResult }
}
}
\ No newline at end of file
......@@ -6,7 +6,6 @@ import com.isidroid.job.data.source.local.JobDao
import com.isidroid.job.data.source.local.JobLocalSource
import com.isidroid.job.data.source.remote.JobNetworkSource
import com.isidroid.job.data.source.remote.api.ApiJob
import com.isidroid.job.repository.JobRepository
import com.isidroid.job.repository.JobRepositoryImpl
import com.isidroid.test_utils.MockWebServerWrapper
import com.isidroid.test_utils.createApi
......@@ -19,7 +18,6 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
......@@ -49,7 +47,7 @@ class TestPrintJob {
networkSource = JobNetworkSource(api)
localSource = JobLocalSource(jobDao)
jobRepository = JobRepositoryImpl(mockContext, networkSource, localSource)
jobRepository = JobRepositoryImpl(networkSource, localSource)
}
......
/build
\ No newline at end of file
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.isidroid.job"
compileSdk = BuildVersions.COMPILE_SDK
defaultConfig {
minSdk = BuildVersions.MIN_SDK
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
api(project(":core:network"))
api(project((":core:core")))
api(project((":core:utils")))
api(project(":feature:rendering"))
implementation(project(":feature:job"))
api(project(":feature:spot"))
testApi(project((":core:test_utils")))
api("androidx.core:core-ktx:${GoogleVersions.coreKtx}")
api("androidx.appcompat:appcompat:${GoogleVersions.appCompat}")
api("com.google.dagger:hilt-android:${GoogleVersions.hilt}")
api("androidx.work:work-runtime-ktx:${GoogleVersions.work}")
api("androidx.hilt:hilt-work:${GoogleVersions.work_hilt}")
api("androidx.room:room-runtime:${GoogleVersions.room}")
ksp("androidx.hilt:hilt-compiler:${GoogleVersions.work_hilt}")
ksp("com.google.dagger:hilt-compiler:${GoogleVersions.hilt}")
ksp("androidx.room:room-compiler:${GoogleVersions.room}")
// Firebase
api("com.google.firebase:firebase-crashlytics-ktx")
// tools
api("com.jakewharton.timber:timber:${ToolsVersions.timber}")
testImplementation("junit:junit:${TestVersions.junit}")
androidTestImplementation("androidx.test.ext:junit:${TestVersions.junitExt}")
androidTestImplementation("androidx.test.espresso:espresso-core:${TestVersions.espressoCore}")
}
\ No newline at end of file
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
\ No newline at end of file
package com.isidroid.job_sender
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.isidroid.job_sender.test", appContext.packageName)
}
}
\ No newline at end of file
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
\ No newline at end of file
package com.isidroid.job_sender
class RenderBitmapProfileException(m: String? = null): Throwable(m)
class UploadPageException(m: String? = null): Throwable(m)
\ No newline at end of file
package com.isidroid.job_sender
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.model.PrintJob
import com.isidroid.job_sender.domain.dto.JobSenderResult
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.buffer
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SendJobEventCollectorFlow @Inject constructor() {
private val _eventsFlow = MutableSharedFlow<JobSenderResult>(replay = 1)
val eventsFlow = _eventsFlow.asSharedFlow().buffer(capacity = 10)
private suspend fun emit(result: JobSenderResult) = _eventsFlow.emit(result)
.also { Timber.i("call: $result") }
suspend fun renderProgress(index: Int, total: Int) = emit(JobSenderResult.RenderProgress(index, total))
suspend fun updateProgress(index: Int, total: Int) = emit(JobSenderResult.UploadProgress(index, total))
suspend fun jobError(jobId: String, t: Throwable) = emit(JobSenderResult.Error(jobId, t))
suspend fun updateStatus(@JobStatus status: Int, vararg job: PrintJob?) {
val jobList = job.toList().filterNotNull()
if (jobList.isNotEmpty())
emit(JobSenderResult.Statuses(status = status, jobs = jobList))
}
}
\ No newline at end of file
package com.isidroid.c23.data.worker.job
package com.isidroid.job_sender
import android.content.Context
import androidx.hilt.work.HiltWorker
......@@ -10,27 +10,43 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.isidroid.job_sender.domain.use_case.SendPrintJobsUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
internal class SendJobWorker @AssistedInject constructor(
class SendJobWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
// @Inject private val collectorFlow: SendJobEventCollectorFlow
private val useCase: SendPrintJobsUseCase,
private val collector: SendJobEventCollectorFlow
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = coroutineScope {
val collectorJob = launch { runCollector() }
try {
useCase.start()
Result.success()
} catch (t: Throwable) {
Timber.e(t)
Result.failure()
} finally {
collectorJob.cancel()
}
}
private suspend fun runCollector() {
coroutineScope {
collector.eventsFlow.collect {
}
}
}
companion object {
......@@ -49,7 +65,7 @@ internal class SendJobWorker @AssistedInject constructor(
.setId(UUID.randomUUID())
// We use KEEP to avoid duplicate runs of SyncRepository
workManager.beginUniqueWork(SendJobWorker::class.java.simpleName, ExistingWorkPolicy.KEEP, requestBuilder.build()).enqueue()
workManager.beginUniqueWork(SendJobWorker::class.java.simpleName, ExistingWorkPolicy.REPLACE, requestBuilder.build()).enqueue()
}
}
}
\ No newline at end of file
package com.isidroid.job_sender.data.source.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.isidroid.job_sender.domain.model.PrintJobSender
@Dao
interface SendJobDao {
@Query("SELECT * FROM PrintJobSender WHERE id = :id AND printJobId = :printJobId")
fun find(id: String, printJobId: String): PrintJobSender?
@Query("SELECT * FROM PrintJobSender WHERE status = :status")
fun findByStatus(status: Int): List<PrintJobSender>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg item: PrintJobSender)
}
\ No newline at end of file
package com.isidroid.job_sender.data.source.local
import com.isidroid.job.constant.SendJobStatus
import com.isidroid.job_sender.domain.model.PrintJobSender
class SendJobLocalSource(private val dao: SendJobDao) : SendJobDao {
override fun find(id: String, printJobId: String): PrintJobSender? = dao.find(id, printJobId)
override fun findByStatus(status: Int) = dao.findByStatus(status)
override fun insert(vararg item: PrintJobSender) = dao.insert(*item)
fun updateStatus(item: PrintJobSender, @SendJobStatus status: Int) = item.copy(status = status).also { insert(it) }
}
\ No newline at end of file
package com.isidroid.job_sender.di
import com.isidroid.job.repository.JobRepository
import com.isidroid.job_sender.SendJobEventCollectorFlow
import com.isidroid.job_sender.data.source.local.SendJobDao
import com.isidroid.job_sender.data.source.local.SendJobLocalSource
import com.isidroid.job_sender.repository.JobSendRepository
import com.isidroid.job_sender.repository.JobSendRepositoryImpl
import com.isidroid.rendering.repository.RenderRepository
import com.isidroid.spot.repository.SpotRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object JobSenderModule {
@Provides @Singleton
fun provideSendJobLocalSource(dao: SendJobDao) = SendJobLocalSource(dao)
@Provides @Singleton
fun provideJobSendRepository(
renderRepository: RenderRepository,
spotRepository: SpotRepository,
printJobRepository: JobRepository,
sendJobLocalSource: SendJobLocalSource,
eventCollector: SendJobEventCollectorFlow,
): JobSendRepository = JobSendRepositoryImpl(renderRepository, spotRepository, printJobRepository, sendJobLocalSource, eventCollector)
}
\ No newline at end of file
package com.isidroid.job_sender.domain.dto
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.model.PrintJob
sealed interface JobSenderResult {
data class RenderProgress(val position: Int, val total: Int) : JobSenderResult
data class UploadProgress(val position: Int, val total: Int) : JobSenderResult
data class Error(val jobId: String, val t: Throwable) : JobSenderResult
data class Statuses(@JobStatus val status: Int, val jobs: Collection<PrintJob>): JobSenderResult
}
\ No newline at end of file
package com.isidroid.job_sender.domain.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.isidroid.job.constant.SendJobStatus
import java.util.Date
import java.util.UUID
@Entity
data class PrintJobSender(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val printJobId: String = "",
@SendJobStatus val status: Int,
val sourceFile: String = "",
val accessToken: String = "",
val createdAt: Date = Date(),
val updatedAt: Date = Date(),
)
\ No newline at end of file
package com.isidroid.job_sender.domain.use_case
import com.isidroid.core.DiMock
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.constant.SendJobStatus
import com.isidroid.job.repository.JobRepository
import com.isidroid.job_sender.SendJobEventCollectorFlow
import com.isidroid.job_sender.data.source.local.SendJobLocalSource
import com.isidroid.job_sender.ext.createRenderItems
import com.isidroid.job_sender.repository.JobSendRepository
import com.isidroid.rendering.repository.RenderRepository
import com.isidroid.spot.repository.SpotRepository
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SendPrintJobsUseCase @Inject constructor(
private val repository: JobSendRepository,
@DiMock private val debug: Boolean
) {
suspend fun start() {
repository.checkNotRenderedPrintJobs(repository.getJobList(JobStatus.IDLE))
repository.readFilesAndSend(repository.getJobSenderList(SendJobStatus.RENDERED))
repository.markJobUploaded(repository.getJobList(JobStatus.RENDER_UPLOAD))
}
}
\ No newline at end of file
package com.isidroid.job_sender.ext
internal fun MutableMap<String, Int>.decreaseCounter(key: String) {
val current = get(key) ?: return
put(key, current - 1)
}
\ No newline at end of file
package com.isidroid.job_sender.ext
import android.graphics.BitmapFactory
import com.isidroid.job.constant.SendJobStatus
import com.isidroid.job.model.PrintJob
import com.isidroid.job_sender.RenderBitmapProfileException
import com.isidroid.job_sender.SendJobEventCollectorFlow
import com.isidroid.job_sender.domain.model.PrintJobSender
import com.isidroid.rendering.model.RenderSettingsV2
import com.isidroid.rendering.repository.RenderRepository
import com.isidroid.spot.model.PrintProfile
import com.isidroid.spot.model.RichSpot
import com.isidroid.spot.repository.SpotRepository
import com.isidroid.utils.md5
import com.isidroid.utils.saveToFile
import java.io.File
import java.util.UUID
internal fun renderBitmapForPrint(filePath: String, printProfile: PrintProfile?, printJob: PrintJob, renderRepository: RenderRepository): File {
printProfile ?: throw RenderBitmapProfileException("Print profile is null")
val picture = BitmapFactory.decodeFile(filePath)
val file = File.createTempFile(UUID.randomUUID().toString(), ".jpg")
val updatedSettings = RenderSettingsV2(
filePath = file.absolutePath,
greyscale = printProfile.grayscale,
isRealSize = true,
orientation = printJob.printOrientation,
printSize = printJob.printSize,
)
val result = renderRepository.renderBitmap(
width = printProfile.width,
height = printProfile.height,
printWidth = printProfile.printWidth,
printHeight = printProfile.printHeight,
marginTop = printProfile.marginTop,
marginLeft = printProfile.marginLeft,
dpix = printProfile.dpix,
dpiy = printProfile.dpiy,
renderSettingsV2 = updatedSettings,
picture = picture,
)
result.saveToFile(file)
return file
}
internal fun createSingleRender(renderRepository: RenderRepository, richSpots: List<RichSpot>?, printJob: PrintJob, source: String): PrintJobSender? {
val printJobId = printJob.id
val richSpot = richSpots?.find { it.spot.id == printJob.spotId } ?: return null
val printProfile = richSpot.printProfiles.find { it.id == printJob.profileId } ?: return null
val id = "${source}_${printJobId}".md5
val file = renderBitmapForPrint(
filePath = source,
printProfile = printProfile,
printJob = printJob,
renderRepository = renderRepository
)
return PrintJobSender(
id = id,
printJobId = printJobId,
status = SendJobStatus.RENDERED,
sourceFile = file.absolutePath,
accessToken = printJob.accessToken.orEmpty(),
)
}
internal suspend fun createRenderItems(
spotRepository: SpotRepository,
renderRepository: RenderRepository,
eventCollector: SendJobEventCollectorFlow,
jobIdleList: Collection<PrintJob>
): Collection<PrintJobSender> {
val richSpots = spotRepository.findLocalRichSpots(ids = jobIdleList.map { it.spotId })
var index = 0
val total = jobIdleList.size
val result = jobIdleList.mapNotNull { printJob ->
printJob.sourceFiles?.mapNotNull { source ->
createSingleRender(renderRepository, richSpots, printJob, source)
.also { eventCollector.renderProgress(++index, total) }
}
}.flatten()
return result
}
\ No newline at end of file
package com.isidroid.job_sender.repository
import com.isidroid.job.model.PrintJob
import com.isidroid.job_sender.domain.model.PrintJobSender
interface JobSendRepository {
suspend fun getJobList(status: Int): Collection<PrintJob>
suspend fun getJobSenderList(status: Int): Collection<PrintJobSender>
suspend fun checkNotRenderedPrintJobs(items: Collection<PrintJob>): Collection<PrintJobSender>?
suspend fun readFilesAndSend(items: Collection<PrintJobSender>): Collection<PrintJob>
suspend fun markJobUploaded(items: Collection<PrintJob>): Collection<PrintJob>
}
\ No newline at end of file
package com.isidroid.job_sender.repository
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.model.PrintJob
import com.isidroid.job.repository.JobRepository
import com.isidroid.job_sender.SendJobEventCollectorFlow
import com.isidroid.job_sender.UploadPageException
import com.isidroid.job_sender.data.source.local.SendJobLocalSource
import com.isidroid.job_sender.domain.model.PrintJobSender
import com.isidroid.job_sender.ext.createRenderItems
import com.isidroid.job_sender.ext.decreaseCounter
import com.isidroid.rendering.repository.RenderRepository
import com.isidroid.spot.repository.SpotRepository
import kotlinx.coroutines.delay
import timber.log.Timber
internal class JobSendRepositoryImpl(
private val renderRepository: RenderRepository,
private val spotRepository: SpotRepository,
private val printJobRepository: JobRepository,
private val sendJobLocalSource: SendJobLocalSource,
private val eventCollector: SendJobEventCollectorFlow,
) : JobSendRepository {
override suspend fun getJobList(status: Int): Collection<PrintJob> = printJobRepository.readLocalList(status = status)
override suspend fun getJobSenderList(status: Int): Collection<PrintJobSender> = sendJobLocalSource.findByStatus(status)
// let's check whether there is not rendered print jobs
override suspend fun checkNotRenderedPrintJobs(items: Collection<PrintJob>): Collection<PrintJobSender>? {
if (items.isNotEmpty()) {
val renderItems = createRenderItems(spotRepository, renderRepository, eventCollector, items)
val jobIds = renderItems.map { it.printJobId }.distinct()
printJobRepository.updateJobStatus(JobStatus.CREATED, *jobIds.toTypedArray())
sendJobLocalSource.insert(*renderItems.toTypedArray())
return renderItems
}
return null
}
// get all pending jobs and send them one by one
override suspend fun readFilesAndSend(items: Collection<PrintJobSender>): Collection<PrintJob> {
val total = items.size
val sendJobResults = items.groupBy { it.printJobId }.mapValues { it.value.size }.toMutableMap()
for ((index, item) in items.withIndex()) {
eventCollector.updateProgress(index, total)
try {
val uploadResult = printJobRepository.uploadPage(jobId = item.printJobId, token = item.accessToken, filePath = item.sourceFile)
if (!uploadResult)
throw UploadPageException()
sendJobResults.decreaseCounter(item.printJobId)
} catch (t: Throwable) {
Timber.e(t)
eventCollector.jobError(jobId = item.printJobId, t = t)
}
}
eventCollector.updateProgress(total, total)
// update jobs
val jobsIds = sendJobResults.filter { it.value == 0 }.keys
printJobRepository.updateJobStatus(status = JobStatus.RENDER_UPLOAD, *jobsIds.toTypedArray())
val jobs = printJobRepository.readLocalList(ids = jobsIds.toList())
// notify event emitters
eventCollector.updateStatus(status = JobStatus.RENDER_UPLOAD, *jobs.toTypedArray())
return jobs
}
override suspend fun markJobUploaded(items: Collection<PrintJob>): Collection<PrintJob> {
return items.mapNotNull { job ->
// upload
val updateJob = printJobRepository.completeUpload(jobId = job.id, token = job.accessToken.orEmpty())
// notify event emitters
eventCollector.updateStatus(status = JobStatus.READY_TO_PRINT, updateJob)
updateJob
}
}
}
package com.isidroid.job_sender
import org.junit.Test
import org.junit.Assert.*
import java.util.UUID
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
val items = listOf(
Item(parentId = "1"),
Item(parentId = "1"),
Item(parentId = "2"),
)
val data: Map<String, Int> = items.groupBy { it.parentId }.mapValues { it.value.size }
println("$data")
}
}
data class Item(val parentId: String, val id: String = UUID.randomUUID().toString())
\ No newline at end of file
package com.isidroid.job_sender
import com.isidroid.job.constant.JobStatus
import com.isidroid.job.constant.SendJobStatus
import com.isidroid.job.data.source.local.JobLocalSource
import com.isidroid.job.data.source.remote.JobNetworkSource
import com.isidroid.job.data.source.remote.api.ApiJob
import com.isidroid.job.model.PrintJob
import com.isidroid.job.repository.JobRepository
import com.isidroid.job_sender.data.source.local.SendJobLocalSource
import com.isidroid.job_sender.domain.dto.JobSenderResult
import com.isidroid.job_sender.domain.model.PrintJobSender
import com.isidroid.job_sender.repository.JobSendRepositoryImpl
import com.isidroid.rendering.repository.RenderRepository
import com.isidroid.spot.repository.SpotRepository
import com.isidroid.test_utils.MockWebServerWrapper
import com.isidroid.test_utils.createApi
import com.isidroid.test_utils.isMethodPost
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class UploadJobsTest {
private lateinit var api: ApiJob
private lateinit var mockWebServer: MockWebServer
private lateinit var renderRepository: RenderRepository
private lateinit var printJobRepository: JobRepository
private lateinit var repository: JobSendRepositoryImpl
private lateinit var spotRepository: SpotRepository
private lateinit var sendJobLocalSource: SendJobLocalSource
private lateinit var eventCollector: SendJobEventCollectorFlow
private lateinit var jobLocalSource: JobLocalSource
private lateinit var jobNetworkSource: JobNetworkSource
private val mockWrapper = MockWebServerWrapper { path, method, body ->
when {
path == "/api/job" && method.isMethodPost -> "create_job"
else -> ""
}
}
@Before
fun setup() {
mockWebServer = mockWrapper.create()
api = createApi(mockWebServer, ApiJob::class.java)
renderRepository = mock(RenderRepository::class.java)
spotRepository = mock(SpotRepository::class.java)
jobLocalSource = mock(JobLocalSource::class.java)
jobNetworkSource = JobNetworkSource(api)
printJobRepository = mock(JobRepository::class.java)
sendJobLocalSource = mock(SendJobLocalSource::class.java)
eventCollector = SendJobEventCollectorFlow()
repository = JobSendRepositoryImpl(renderRepository, spotRepository, printJobRepository, sendJobLocalSource, eventCollector)
}
@After
fun shutdown() {
mockWebServer.shutdown()
}
@Test
fun testSuccessRenderAndUploadSinglePrintJob() = runBlocking {
val delay = 100L
val printJobSender = PrintJobSender(status = SendJobStatus.RENDERED, printJobId = "jobId")
val items = listOf(printJobSender)
val jobs = listOf(PrintJob(id = printJobSender.printJobId))
Mockito.`when`(printJobRepository.readLocalList(ids = items.map { it.printJobId }.toList())).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = emptyList())).thenReturn(emptyList())
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "", "")).thenReturn(true)
val events = mutableListOf<JobSenderResult>()
val deferred = async { eventCollector.eventsFlow.toList(events) }
delay(delay)
val result = repository.readFilesAndSend(items)
Assert.assertEquals(1, result.size)
delay(delay)
deferred.cancel()
val actualEvent1Progress = (events[0] as? JobSenderResult.UploadProgress)?.position
val actualEvent2Progress = (events[1] as? JobSenderResult.UploadProgress)?.position
val actualCountUploadStatus = events.filterIsInstance<JobSenderResult.Statuses>().count { it.status == JobStatus.RENDER_UPLOAD }
Assert.assertEquals("invalid progress for first event", 0, actualEvent1Progress)
Assert.assertEquals("invalid progress for last progress event", 1, actualEvent2Progress)
Assert.assertEquals("incorrect count of rendered print jobs ready to be sent", 1, actualCountUploadStatus)
}
@Test
fun testSuccessRenderAndUploadMultiplePrintJobs() = runBlocking {
val delay = 100L
val printJobSender = PrintJobSender(id = "1", status = SendJobStatus.RENDERED, printJobId = "jobId")
val items = listOf(printJobSender, printJobSender.copy(id = "2"))
val jobs = listOf(PrintJob(id = printJobSender.printJobId))
Mockito.`when`(printJobRepository.readLocalList(ids = items.map { it.printJobId }.distinct().toList())).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = emptyList())).thenReturn(emptyList())
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "", "")).thenReturn(true)
val events = mutableListOf<JobSenderResult>()
val deferred = async { eventCollector.eventsFlow.toList(events) }
delay(delay)
val result = repository.readFilesAndSend(items)
Assert.assertEquals(1, result.size)
delay(delay)
deferred.cancel()
val actualEventTotalProgressCount = events.filterIsInstance<JobSenderResult.UploadProgress>().distinctBy { it.total }.let { if (it.size == 1) it[0].total else -1 }
val actualEvent1Progress = (events[0] as? JobSenderResult.UploadProgress)?.position
val actualEvent2Progress = (events[1] as? JobSenderResult.UploadProgress)?.position
val actualCountUploadStatus = events.filterIsInstance<JobSenderResult.Statuses>().count { it.status == JobStatus.RENDER_UPLOAD }
Assert.assertEquals("invalid progress for first event", 0, actualEvent1Progress)
Assert.assertEquals("invalid progress for last progress event", 1, actualEvent2Progress)
Assert.assertEquals("incorrect count of rendered print jobs ready to be sent", 1, actualCountUploadStatus)
Assert.assertEquals("incorrect count of total jobs", 2, actualEventTotalProgressCount)
}
@Test
fun testFailRenderAndUploadSinglePrintJob() = runBlocking {
val delay = 100L
val printJobSender = PrintJobSender(status = SendJobStatus.RENDERED, printJobId = "jobId")
val items = listOf(printJobSender)
val jobs = listOf(PrintJob(id = printJobSender.printJobId))
Mockito.`when`(printJobRepository.readLocalList(ids = items.map { it.printJobId }.toList())).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = emptyList())).thenReturn(emptyList())
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "", "")).thenReturn(false)
val events = mutableListOf<JobSenderResult>()
val deferred = async { eventCollector.eventsFlow.toList(events) }
delay(delay)
val result = repository.readFilesAndSend(items)
Assert.assertEquals(0, result.size)
delay(delay)
deferred.cancel()
val actualEvent1Progress = (events[0] as? JobSenderResult.UploadProgress)?.position
val actualEvent2Progress = (events[2] as? JobSenderResult.UploadProgress)?.position
val actualCountUploadStatus = events.filterIsInstance<JobSenderResult.Statuses>().count { it.status == JobStatus.RENDER_UPLOAD }
val actualEventJobErrorsCount = events.filterIsInstance<JobSenderResult.Error>().count()
println("events=${events.mapIndexed { index, jobSenderResult -> "$index). ${jobSenderResult.javaClass.simpleName}" }}")
Assert.assertEquals("invalid progress for first event", 0, actualEvent1Progress)
Assert.assertEquals("invalid progress for last progress event", 1, actualEvent2Progress)
Assert.assertEquals("incorrect count of rendered print jobs ready to be sent", 0, actualCountUploadStatus)
Assert.assertEquals("error job events count", 1, actualEventJobErrorsCount)
}
@Test
fun testSomeFails() = runBlocking {
val delay = 100L
val printJobSender = PrintJobSender(id = "1", status = SendJobStatus.RENDERED, printJobId = "jobId", accessToken = "1")
val items = listOf(printJobSender, printJobSender.copy(id = "2", accessToken = "2"))
val jobs = listOf(PrintJob(id = printJobSender.printJobId))
Mockito.`when`(printJobRepository.readLocalList(ids = items.map { it.printJobId }.distinct().toList())).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = emptyList())).thenReturn(emptyList())
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "1", "")).thenReturn(true)
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "2", "")).thenReturn(false)
val events = mutableListOf<JobSenderResult>()
val deferred = async { eventCollector.eventsFlow.toList(events) }
delay(delay)
val result = repository.readFilesAndSend(items)
// we're expecting zero because there are some PrintJob with unsent jobs
Assert.assertEquals(0, result.size)
delay(delay)
deferred.cancel()
val actualEventTotalProgressCount = events.filterIsInstance<JobSenderResult.UploadProgress>().distinctBy { it.total }.let { if (it.size == 1) it[0].total else -1 }
val actualEvent1Progress = (events[0] as? JobSenderResult.UploadProgress)?.position
val actualEvent2Progress = (events[1] as? JobSenderResult.UploadProgress)?.position
val actualCountUploadStatus = events.filterIsInstance<JobSenderResult.Statuses>().count { it.status == JobStatus.RENDER_UPLOAD }
Assert.assertEquals("invalid progress for first event", 0, actualEvent1Progress)
Assert.assertEquals("invalid progress for last progress event", 1, actualEvent2Progress)
Assert.assertEquals("incorrect count of rendered print jobs ready to be sent", 0, actualCountUploadStatus)
Assert.assertEquals("incorrect count of total jobs", 2, actualEventTotalProgressCount)
}
@Test
fun testOneJobSentButOtherDoesnt() = runBlocking {
val delay = 100L
val printJobSender = PrintJobSender(id = "1", status = SendJobStatus.RENDERED, printJobId = "jobId", accessToken = "1")
val jobId2 = "jobId2"
val items = listOf(
printJobSender,
printJobSender.copy(id = "2", accessToken = "2"),
printJobSender.copy(id = "3", accessToken = "3", printJobId = jobId2)
)
val jobs = listOf(PrintJob(id = printJobSender.printJobId), PrintJob(id = jobId2))
Mockito.`when`(printJobRepository.readLocalList(ids = items.map { it.printJobId }.distinct().toList())).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = listOf(jobId2))).thenReturn(jobs)
Mockito.`when`(printJobRepository.readLocalList(ids = emptyList())).thenReturn(emptyList())
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "1", "")).thenReturn(true)
Mockito.`when`(printJobRepository.uploadPage(printJobSender.printJobId, "2", "")).thenReturn(false)
Mockito.`when`(printJobRepository.uploadPage(jobId2, "3", "")).thenReturn(true)
val events = mutableListOf<JobSenderResult>()
val deferred = async { eventCollector.eventsFlow.toList(events) }
delay(delay)
val result = repository.readFilesAndSend(items)
// we're expecting zero because there are some PrintJob with unsent jobs
Assert.assertEquals(2, result.size)
delay(delay)
deferred.cancel()
val actualEventTotalProgressCount = events.filterIsInstance<JobSenderResult.UploadProgress>().distinctBy { it.total }.let { if (it.size == 1) it[0].total else -1 }
val actualEvent1Progress = (events[0] as? JobSenderResult.UploadProgress)?.position
val actualEvent2Progress = (events[1] as? JobSenderResult.UploadProgress)?.position
val actualCountUploadStatus = events.filterIsInstance<JobSenderResult.Statuses>().count { it.status == JobStatus.RENDER_UPLOAD }
Assert.assertEquals("invalid progress for first event", 0, actualEvent1Progress)
Assert.assertEquals("invalid progress for last progress event", 1, actualEvent2Progress)
Assert.assertEquals("incorrect count of rendered print jobs ready to be sent", 1, actualCountUploadStatus)
Assert.assertEquals("incorrect count of total jobs", 3, actualEventTotalProgressCount)
}
}
\ No newline at end of file
......@@ -16,7 +16,8 @@ internal fun createRenderBitmap(
bitmapConfig: Bitmap.Config
): Bitmap {
val noMargins = settings.printSize in arrayOf(PrintSize.FIT_TO_PAPER, PrintSize.FILL_PAGE)
val hasBorders = settings.showPrintableArea //settings.printSize in arrayOf(PrintSize.FIT_TO_PAPER, PrintSize.FILL_PAGE, PrintSize.PAPER_ORIGINAL_PAGE)
val hasBorders =
settings.showPrintableArea //settings.printSize in arrayOf(PrintSize.FIT_TO_PAPER, PrintSize.FILL_PAGE, PrintSize.PAPER_ORIGINAL_PAGE)
val scaledCanvasSize = getScaledPrintCanvas(
filePath = settings.filePath,
......@@ -25,7 +26,7 @@ internal fun createRenderBitmap(
printCanvas = printCanvas,
)
val scaleFactor = settings.canvasWidth / scaledCanvasSize.width
val scaleFactor = if (settings.canvasWidth > 0) settings.canvasWidth / scaledCanvasSize.width else 1f
val scaledCanvas2 = scaledCanvasSize.copy(
width = scaleFactor * scaledCanvasSize.width,
height = scaleFactor * scaledCanvasSize.height,
......
......@@ -9,8 +9,8 @@ data class RenderSettingsV2(
val isRealSize: Boolean = false,
val filePath: String = "",
@PrintOrientation val orientation: Int = PrintOrientation.AUTO,
@PrintSize val printSize: Int = PrintSize.FIT_TO_PAPER,
val orientation: Int = PrintOrientation.AUTO,
val printSize: Int = PrintSize.FIT_TO_PAPER,
val copies: Int = 1,
val canvasWidth: Float = 0f,
......
......@@ -7,14 +7,14 @@ import com.isidroid.rendering.model.RenderSettingsV2
interface RenderRepository {
fun renderBitmap(
width: Int,
height: Int,
printWidth: Int,
printHeight: Int,
marginTop: Int,
marginLeft: Int,
dpix: Int,
dpiy: Int,
width: Int = 0,
height: Int= 0,
printWidth: Int= 0,
printHeight: Int= 0,
marginTop: Int= 0,
marginLeft: Int= 0,
dpix: Int= 0,
dpiy: Int= 0,
renderSettingsV2: RenderSettingsV2,
picture: Bitmap
): Bitmap
......
......@@ -6,13 +6,13 @@ import androidx.room.PrimaryKey
@Entity(indices = [Index("code")])
data class Spot(
@PrimaryKey val id: String,
val name: String,
@PrimaryKey val id: String = "",
val name: String = "",
val description: String? = null,
val code: String,
val address: String,
val status: String?,
val distance: Float,
val lng: Double,
val lat: Double,
val code: String = "",
val address: String = "",
val status: String? = null,
val distance: Float = 0f,
val lng: Double = 0.0,
val lat: Double = 0.0,
)
\ No newline at end of file
......@@ -5,6 +5,6 @@ import com.isidroid.spot.model.RichSpot
interface SpotRepository {
suspend fun locateSpots(lat: Double, lng: Double, distance: Int): List<RichSpot>
suspend fun findRichSpot(code: String?): RichSpot?
suspend fun finLocalRichSpots(ids: List<String>): List<RichSpot>?
suspend fun findLocalRichSpots(ids: List<String>): List<RichSpot>?
suspend fun save(richSpot: RichSpot?)
}
\ No newline at end of file
......@@ -19,7 +19,7 @@ internal class SpotRepositoryImpl(
override suspend fun findRichSpot(code: String?): RichSpot? = spotNetworkSource.find(code)
override suspend fun finLocalRichSpots(ids: List<String>): List<RichSpot>? {
override suspend fun findLocalRichSpots(ids: List<String>): List<RichSpot>? {
val spots = spotLocalSource.findSpots(ids)
val profiles = printProfileLocalSource.findProfilesInSpots(ids).convertToMap()
return spots.transformToRichSpot(profiles)
......
......@@ -30,3 +30,4 @@ include(":ui:maps")
include(":feature:ms_office")
include(":feature:job")
include(":ui:print_job")
include(":feature:job_sender")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment