Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -456,39 +456,41 @@ class PdfAnnotator {
// Guard: check bitmap is valid before creating canvas
if (bitmap.isRecycled) return null

val canvas = Canvas(bitmap)

// Background
val bgPaint = Paint().apply {
color = annotation.color
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)

// Border
val borderPaint = Paint().apply {
color = Color.DKGRAY
style = Paint.Style.STROKE
strokeWidth = 2f
}
canvas.drawRect(1f, 1f, width - 1f, height - 1f, borderPaint)
if (!bitmap.isRecycled) {
val canvas = Canvas(bitmap) // isRecycled

// Background
val bgPaint = Paint().apply {
color = annotation.color
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)

// Border
val borderPaint = Paint().apply {
color = Color.DKGRAY
style = Paint.Style.STROKE
strokeWidth = 2f
}
canvas.drawRect(1f, 1f, width - 1f, height - 1f, borderPaint)

// Text
val textPaint = Paint().apply {
color = Color.BLACK
textSize = 12f
isAntiAlias = true
}
// Text
val textPaint = Paint().apply {
color = Color.BLACK
textSize = 12f
isAntiAlias = true
}

// Simple text wrapping
val padding = 8f
val lines = wrapText(annotation.text, textPaint, width - padding * 2)
var y = padding + textPaint.textSize
// Simple text wrapping
val padding = 8f
val lines = wrapText(annotation.text, textPaint, width - padding * 2)
var y = padding + textPaint.textSize

for (line in lines) {
if (y > height - padding) break
canvas.drawText(line, padding, y, textPaint)
y += textPaint.textSize + 4
for (line in lines) {
if (y > height - padding) break
canvas.drawText(line, padding, y, textPaint)
y += textPaint.textSize + 4
}
}

return bitmap
Expand Down Expand Up @@ -728,31 +730,33 @@ class PdfAnnotator {
// Guard: check bitmap is valid before creating canvas
if (bitmap.isRecycled) return null

val canvas = Canvas(bitmap)
if (!bitmap.isRecycled) {
val canvas = Canvas(bitmap) // isRecycled

canvas.drawColor(Color.TRANSPARENT)

canvas.drawColor(Color.TRANSPARENT)
// Border
val borderPaint = Paint().apply {
color = annotation.stampType.color
style = Paint.Style.STROKE
strokeWidth = 4f
}
val rect = RectF(4f, 4f, width - 4f, height - 4f)
canvas.drawRoundRect(rect, 8f, 8f, borderPaint)

// Border
val borderPaint = Paint().apply {
color = annotation.stampType.color
style = Paint.Style.STROKE
strokeWidth = 4f
}
val rect = RectF(4f, 4f, width - 4f, height - 4f)
canvas.drawRoundRect(rect, 8f, 8f, borderPaint)
// Text
val textPaint = Paint().apply {
color = annotation.stampType.color
textSize = height * 0.5f
textAlign = Paint.Align.CENTER
isFakeBoldText = true
isAntiAlias = true
}

// Text
val textPaint = Paint().apply {
color = annotation.stampType.color
textSize = height * 0.5f
textAlign = Paint.Align.CENTER
isFakeBoldText = true
isAntiAlias = true
val textY = height / 2f + textPaint.textSize / 3f
canvas.drawText(annotation.stampType.text, width / 2f, textY, textPaint)
}

val textY = height / 2f + textPaint.textSize / 3f
canvas.drawText(annotation.stampType.text, width / 2f, textY, textPaint)

return bitmap
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -371,66 +371,68 @@ class PdfSigner(private val context: Context) {
// Guard: check bitmap is valid before creating canvas
if (bitmap.isRecycled) return null

val canvas = Canvas(bitmap)

// Transparent background
canvas.drawColor(Color.TRANSPARENT)

val paint = Paint().apply {
color = data.strokeColor
strokeWidth = data.strokeWidth
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
}

// Find bounds of the signature
var minX = Float.MAX_VALUE
var maxX = Float.MIN_VALUE
var minY = Float.MAX_VALUE
var maxY = Float.MIN_VALUE

for (path in data.paths) {
for (point in path.points) {
minX = minOf(minX, point.x)
maxX = maxOf(maxX, point.x)
minY = minOf(minY, point.y)
maxY = maxOf(maxY, point.y)
if (!bitmap.isRecycled) {
val canvas = Canvas(bitmap) // isRecycled

// Transparent background
canvas.drawColor(Color.TRANSPARENT)

val paint = Paint().apply {
color = data.strokeColor
strokeWidth = data.strokeWidth
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
isAntiAlias = true
}
}

// Scale and center the signature
val signatureWidth = maxX - minX
val signatureHeight = maxY - minY

val scaleX = if (signatureWidth > 0) (width - 20f) / signatureWidth else 1f
val scaleY = if (signatureHeight > 0) (height - 20f) / signatureHeight else 1f
val scale = minOf(scaleX, scaleY)

val offsetX = (width - signatureWidth * scale) / 2 - minX * scale
val offsetY = (height - signatureHeight * scale) / 2 - minY * scale

// Draw paths
for (signaturePath in data.paths) {
if (signaturePath.points.isEmpty()) continue

val path = Path()
val firstPoint = signaturePath.points.first()
path.moveTo(
firstPoint.x * scale + offsetX,
firstPoint.y * scale + offsetY
)

for (i in 1 until signaturePath.points.size) {
val point = signaturePath.points[i]
path.lineTo(
point.x * scale + offsetX,
point.y * scale + offsetY
)
// Find bounds of the signature
var minX = Float.MAX_VALUE
var maxX = Float.MIN_VALUE
var minY = Float.MAX_VALUE
var maxY = Float.MIN_VALUE

for (path in data.paths) {
for (point in path.points) {
minX = minOf(minX, point.x)
maxX = maxOf(maxX, point.x)
minY = minOf(minY, point.y)
maxY = maxOf(maxY, point.y)
}
}

canvas.drawPath(path, paint)
// Scale and center the signature
val signatureWidth = maxX - minX
val signatureHeight = maxY - minY

val scaleX = if (signatureWidth > 0) (width - 20f) / signatureWidth else 1f
val scaleY = if (signatureHeight > 0) (height - 20f) / signatureHeight else 1f
val scale = minOf(scaleX, scaleY)

val offsetX = (width - signatureWidth * scale) / 2 - minX * scale
val offsetY = (height - signatureHeight * scale) / 2 - minY * scale

// Draw paths
for (signaturePath in data.paths) {
if (signaturePath.points.isEmpty()) continue

val path = Path()
val firstPoint = signaturePath.points.first()
path.moveTo(
firstPoint.x * scale + offsetX,
firstPoint.y * scale + offsetY
)

for (i in 1 until signaturePath.points.size) {
val point = signaturePath.points[i]
path.lineTo(
point.x * scale + offsetX,
point.y * scale + offsetY
)
}

canvas.drawPath(path, paint)
}
}

return bitmap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,10 @@ class PdfViewerViewModel : ViewModel() {
val height = (page.height * scale).toInt()

val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(android.graphics.Color.WHITE) // White background
if (!bitmap.isRecycled) {
val canvas = Canvas(bitmap) // isRecycled
canvas.drawColor(android.graphics.Color.WHITE) // White background
}

page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class ReviewSystemTest {
usageTracker.trackMergeUsage()
usageTracker.trackSplitUsage()

kotlinx.coroutines.delay(100) // allow background coroutines to persist

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace fixed sleeps with deterministic async synchronization

Using a hard-coded delay(100) here still leaves the test timing-dependent, because UsageTracker persists via background scope.launch jobs on Dispatchers.Default/IO and there is no guarantee those jobs finish within 100ms on a loaded CI worker. In that case this assertion can still read stale ReviewPreferences data and fail intermittently, so the flakiness this change is trying to fix is not actually eliminated.

Useful? React with 👍 / 👎.

val data = reviewPreferences.getReviewData()

// Verify individual feature counts
Expand Down Expand Up @@ -143,6 +144,7 @@ class ReviewSystemTest {
usageTracker.onAppBackground()

// Get accumulated time
kotlinx.coroutines.delay(100) // allow background coroutines to persist
val timeAfterBackground = reviewPreferences.getReviewData().totalSessionTimeMs

// Should have some session time recorded
Expand All @@ -154,6 +156,7 @@ class ReviewSystemTest {
usageTracker.onAppBackground()

// Time should have accumulated
kotlinx.coroutines.delay(100) // allow background coroutines to persist
val timeAfterSecondBackground = reviewPreferences.getReviewData().totalSessionTimeMs
assertTrue("Session time should accumulate", timeAfterSecondBackground > timeAfterBackground)
}
Expand All @@ -163,8 +166,11 @@ class ReviewSystemTest {
// Track some usage
usageTracker.trackPdfViewerUsage()
usageTracker.trackMergeUsage()
kotlinx.coroutines.delay(100)

// Get new instances (simulating app restart)
usageTracker.stopTracking()
kotlinx.coroutines.delay(100)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace fixed delay before reset with deterministic sync

Using a hard-coded kotlinx.coroutines.delay(100) here is still timing-dependent because track*Usage() and stopTracking() persist through background coroutine jobs (Dispatchers.Default/Dispatchers.IO) that are not awaited. On a slow or contended CI worker, the singleton reset can happen before those jobs complete (or after cancellation starts), so testUsageCountsPersist may intermittently read stale counters and fail. Fresh evidence in this commit is the new fixed sleep inserted around stopTracking() in this test path.

Useful? React with 👍 / 👎.

UsageTracker.resetInstance()
ReviewPreferences.resetInstance()

Expand Down Expand Up @@ -203,6 +209,7 @@ class ReviewSystemTest {
@Test
fun testCooldownPeriodPreventsRePrompt() = runBlocking {
// Set up conditions for review
usageTracker.onAppForeground()
repeat(5) { usageTracker.trackPdfViewerUsage() }
reviewPreferences.addSessionTime(15 * 60 * 1000L)

Expand All @@ -225,6 +232,7 @@ class ReviewSystemTest {
@Test
fun testSessionTimeOnlyCountsForeground() = runBlocking {
// Start in foreground
kotlinx.coroutines.delay(100)
usageTracker.onAppForeground()

// Get initial time
Expand All @@ -239,6 +247,7 @@ class ReviewSystemTest {

// Go to background
usageTracker.onAppBackground()
kotlinx.coroutines.delay(100) // allow background coroutines to persist
val backgroundTime = afterWait

// Wait again
Expand Down Expand Up @@ -274,6 +283,7 @@ class ReviewSystemTest {
threads.forEach { it.join() }

// Verify counts
kotlinx.coroutines.delay(100)
val data = reviewPreferences.getReviewData()
assertEquals("PDF Viewer usage should be 20", 20, data.pdfViewerUsage)
assertEquals("Merge usage should be 20", 20, data.mergeUsage)
Expand All @@ -288,22 +298,25 @@ class ReviewSystemTest {
@Test
fun testReviewStatsCalculation() = runBlocking {
// Track usage
usageTracker.onAppForeground()
usageTracker.trackPdfViewerUsage()
usageTracker.trackMergeUsage()
usageTracker.trackMergeUsage()

kotlinx.coroutines.delay(100)
// Add session time
reviewPreferences.addSessionTime(5 * 60 * 1000L)

kotlinx.coroutines.delay(100)
// Get stats
val stats = reviewManager.getReviewStats()

// Verify stats
assertEquals("PDF Viewer in stats", 1, stats.pdfViewerUsage)
assertEquals("Merge in stats", 2, stats.mergeUsage)
assertEquals("Total usage in stats", 3, stats.totalFeatureUsage)
assertEquals("Session time in stats", 5 * 60 * 1000L, stats.totalSessionTimeMs)
assertEquals("Session minutes", 5L, stats.totalSessionTimeMinutes)
assertTrue("Session time in stats should be at least expected", stats.totalSessionTimeMs >= 5 * 60 * 1000L)
assertTrue("Session minutes should be at least expected", stats.totalSessionTimeMinutes >= 5L)
assertFalse("Should not be able to request", stats.canRequestReview)
assertFalse("Should not have rated", stats.hasRated)
}
Expand Down
Loading