diff --git a/api.properties b/api.properties index 75ebb7de..e5172feb 100644 --- a/api.properties +++ b/api.properties @@ -1,2 +1,2 @@ -API_KEY="ZKpHA5D3.93jD8IMBSeZW0mRI6815RTHlPkOLzWBx" +API_KEY="cTVQynqM.2HoOaVM63F5MJo9o6Pm6g86DIn0ju6q9" API_BASE_ADDRESS="https://tpe.seemoo.tu-darmstadt.de/api/" \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e57a57c6..3866168d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "de.seemoo.at_tracking_detection" minSdk = 28 targetSdk = 36 - versionCode 63 - versionName "3.0.1" + versionCode 64 + versionName "3.0.2" buildConfigField "String", "API_KEY", apiProperties["API_KEY"] buildConfigField "String", "API_BASE_ADDRESS", apiProperties["API_BASE_ADDRESS"] @@ -182,13 +182,13 @@ dependencies { implementation 'androidx.compose.material3:material3:1.4.0' implementation 'androidx.compose.material3:material3-window-size-class:1.4.0' // Animations - implementation 'androidx.compose.animation:animation:1.10.5' + implementation 'androidx.compose.animation:animation:1.10.6' // Tooling support (Previews, etc.) - implementation 'androidx.compose.ui:ui-tooling:1.10.5' + implementation 'androidx.compose.ui:ui-tooling:1.10.6' // Integration with ViewModels implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' // UI Tests - androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.10.5' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.10.6' // When using a MDC theme implementation "com.google.android.material:compose-theme-adapter:1.2.1" diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt index bce4f932..d2ddd015 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/BackgroundBluetoothScanner.kt @@ -36,6 +36,7 @@ import de.seemoo.at_tracking_detection.util.risk.RiskLevelEvaluator import de.seemoo.at_tracking_detection.worker.BackgroundWorkScheduler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -43,7 +44,6 @@ import timber.log.Timber import java.time.LocalDateTime import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine object BackgroundBluetoothScanner { private var bluetoothAdapter: BluetoothAdapter? = null @@ -348,7 +348,7 @@ object BackgroundBluetoothScanner { return true } - return suspendCoroutine { cont -> + return suspendCancellableCoroutine { cont -> var coroutineFinished = false val handler = Handler(Looper.getMainLooper()) @@ -479,6 +479,8 @@ object BackgroundBluetoothScanner { since = discoveryDate.minusMinutes(TIME_BETWEEN_BEACONS) ) // sorted by newest first + // adding the beacons[0].locationId != locId check results in more than 1 beacon per 15 minutes if location is different + // if (beacons.isEmpty()) { if (beacons.isEmpty() || beacons[0].locationId != locId) { Timber.d("Add new Beacon to the database!") diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt b/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt index 15758178..3bd913e1 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/detection/PermanentBluetoothScanner.kt @@ -32,13 +32,18 @@ import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.Date import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import kotlin.math.abs @RequiresApi(Build.VERSION_CODES.S) object PermanentBluetoothScanner: LocationHistoryListener { private var bluetoothAdapter: BluetoothAdapter? = null - private var executor = Executors.newSingleThreadExecutor() + private val daemonThreadFactory = ThreadFactory { r -> + Thread(r, "PermanentBleScanner").apply { isDaemon = true } + } + + private var executor = Executors.newSingleThreadExecutor(daemonThreadFactory) @Volatile private var keepRunning = false private var pendingFoundDevices: ArrayList = @@ -174,7 +179,7 @@ object PermanentBluetoothScanner: LocationHistoryListener { executor.shutdown() // Optionally, replace the executor with a new one if you need to start again later if (executor.isShutdown || executor.isTerminated) { - executor = Executors.newSingleThreadExecutor() + executor = Executors.newSingleThreadExecutor(daemonThreadFactory) } // Ask orchestrator to stop only if we are the current callback try { diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RallyLineGraphChart.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RallyLineGraphChart.kt deleted file mode 100644 index 7b9a33ec..00000000 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/dashboard/RallyLineGraphChart.kt +++ /dev/null @@ -1,315 +0,0 @@ -package de.seemoo.at_tracking_detection.ui.dashboard - -import android.content.Context -import android.graphics.* -import android.util.AttributeSet -import android.util.TypedValue -import android.view.View -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import de.seemoo.at_tracking_detection.R -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min - - -// https://github.com/hashlin/rally -class RallyLineGraphChart : View { - - private val data = mutableListOf() - private val points = mutableListOf() - private val conPoint1 = mutableListOf() - private val conPoint2 = mutableListOf() - - private val path = Path() - private val borderPath = Path() - private val barPaint = Paint() - private val pathPaint = Paint() - private val borderPathPaint = Paint() - - private var viewCanvas: Canvas? = null - private var bitmap: Bitmap? = null - private val bitmapPaint = Paint(Paint.DITHER_FLAG) - - private var curveTopMargin = 32 - - private val barWidth by lazy { - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, .5f, resources.displayMetrics - ) - } - - private val borderPathWidth by lazy { - TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics - ) - } - - constructor(context: Context?) : super(context) { - init(null) - } - - constructor( - context: Context?, - attrs: AttributeSet? - ) : super(context, attrs) { - init(attrs) - } - - constructor( - context: Context?, - attrs: AttributeSet?, - defStyleAttr: Int - ) : super(context, attrs, defStyleAttr) { - init(attrs) - } - - private fun init(set: AttributeSet?) { - - val ta = context.obtainStyledAttributes(set, R.styleable.RallyLineGraphChart) - val barColor = ta.getColor(R.styleable.RallyLineGraphChart_barColor, Color.GRAY) - val fillColor = - ta.getColor( - R.styleable.RallyLineGraphChart_curveFillColor, - Color.parseColor("#ff2A2931") - ) - val borderColor = - ta.getColor( - R.styleable.RallyLineGraphChart_curveBorderColor, - Color.parseColor("#ff21AF6C") - ) - - curveTopMargin = ta.getDimensionPixelSize(R.styleable.RallyLineGraphChart_curveTopMargin, 0) - ta.recycle() - - barPaint.apply { - isAntiAlias = true - strokeWidth = barWidth - style = Paint.Style.STROKE - color = barColor - } - pathPaint.apply { - isAntiAlias = true - style = Paint.Style.FILL - color = fillColor - } - borderPathPaint.apply { - isAntiAlias = true - strokeWidth = borderPathWidth - style = Paint.Style.STROKE - color = borderColor - } - } - -// private fun measureWidth(widthMeasureSpec: Int): Int { -// return resolveSizeAndState(MeasureSpec.getSize(widthMeasureSpec), widthMeasureSpec, 0) -// } -// -// private fun measureHeight(heightMeasureSpec: Int): Int { -// return resolveSizeAndState(MeasureSpec.getSize(heightMeasureSpec), heightMeasureSpec, 0) -// } - - override fun onSizeChanged( - w: Int, - h: Int, - oldw: Int, - oldh: Int - ) { - super.onSizeChanged(w, h, oldw, oldh) - - bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - viewCanvas = Canvas(bitmap!!) - drawVerticalBars(viewCanvas) - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - drawBezierCurve(canvas) - bitmap?.let { - canvas.drawBitmap(it, 0f, 0f, bitmapPaint) - } - } - - private fun drawVerticalBars(canvas: Canvas?) { - val largeBarHeight = getLargeBarHeight() - val smallBarHeight = height - largeBarHeight / 3 - val barMargin = (width - (barWidth * VERTICAL_BARS)) / VERTICAL_BARS - var startX = 0f - val startY = height.toFloat() - var endX: Float - var endY: Float - - for (i in 0 until VERTICAL_BARS) { - startX += barWidth + barMargin - endX = startX - endY = if (i % INDEX_OF_LARGE_BAR != 2) { - smallBarHeight - } else { - largeBarHeight - } - canvas?.drawLine(startX, startY, endX, endY, barPaint) - } - } - - private fun drawBezierCurve(canvas: Canvas?) { - - try { - - if (points.isEmpty() && conPoint1.isEmpty() && conPoint2.isEmpty()) return - - path.reset() - path.moveTo(points.first().x, points.first().y) - - for (i in 1 until points.size) { - path.cubicTo( - conPoint1[i - 1].x, conPoint1[i - 1].y, conPoint2[i - 1].x, conPoint2[i - 1].y, - points[i].x, points[i].y - ) - } - - borderPath.set(path) - - path.lineTo(width.toFloat(), height.toFloat()) - path.lineTo(0f, height.toFloat()) - - canvas?.drawPath(path, pathPaint) - - canvas?.drawPath(borderPath, borderPathPaint) - - } catch (_: Exception) { - } - } - - private fun calculatePointsForData() { - if (data.isEmpty()) return - - val bottomY = height - CURVE_BOTTOM_MARGIN - val xDiff = - width.toFloat() / (data.size - 1) //subtract -1 because we want to include position at right side - - val maxData = data.maxByOrNull { it.amount }!!.amount - - for (i in 0 until data.size) { - val y = bottomY - (data[i].amount / maxData * (bottomY - curveTopMargin)) - points.add(PointF(xDiff * i, y)) - } - } - - private fun calculateConnectionPointsForBezierCurve() { - try { - for (i in 1 until points.size) { - conPoint1.add(PointF((points[i].x + points[i - 1].x) / 2, points[i - 1].y)) - conPoint2.add(PointF((points[i].x + points[i - 1].x) / 2, points[i].y)) - } - } catch (_: Exception) { - } - } - - private fun getLargeBarHeight() = height / 3 * 2f - - fun addDataPoints(data: List) { - //do calculation in worker thread // Note: You should use some safe thread mechanism - //Calculation logic here are not fine, should updated when more time available - post { - Thread(Runnable { - - val oldPoints = points.toList() - - if (oldPoints.isEmpty()) { - this.data.addAll(data.toList()) - calculatePointsForData() - calculateConnectionPointsForBezierCurve() - postInvalidate() - return@Runnable - } - - resetDataPoints() - this.data.addAll(data.toList()) - calculatePointsForData() - calculateConnectionPointsForBezierCurve() - - val newPoints = points.toList() - - val size = oldPoints.size - var maxDiffY = 0f - for (i in 0 until size) { - val abs = abs(oldPoints[i].y - newPoints[i].y) - if (abs > maxDiffY) maxDiffY = abs - } - - val loopCount = maxDiffY / 16 - - val tempPointsForAnimation = mutableListOf>() - - for (i in 0 until size) { - val old = oldPoints[i] - val new = newPoints[i] - - val plusOrMinusAmount = abs(new.y - old.y) / maxDiffY * 16 - - var tempY = old.y - val tempList = mutableListOf() - - for (j in 0..loopCount.toInt()) { - if (tempY == new.y) { - tempList.add(PointF(new.x, new.y)) - - } else { - - if (new.y > old.y) { - tempY += plusOrMinusAmount - tempY = min(tempY, new.y) - tempList.add(PointF(new.x, tempY)) - - } else { - tempY -= plusOrMinusAmount - tempY = max(tempY, new.y) - tempList.add(PointF(new.x, tempY)) - } - } - } - tempPointsForAnimation.add(tempList) - - } - - if (tempPointsForAnimation.isEmpty()) return@Runnable - - val first = tempPointsForAnimation[0] - val length = first.size - - for (i in 0 until length) { - conPoint1.clear() - conPoint2.clear() - points.clear() - points.addAll(tempPointsForAnimation.map { it[i] }) - calculateConnectionPointsForBezierCurve() - postInvalidate() - Thread.sleep(16) - } - - }).start() - } - } - - private fun resetDataPoints() { - this.data.clear() - points.clear() - conPoint1.clear() - conPoint2.clear() - } - - fun setCurveBorderColor(@ColorRes color: Int) { - borderPathPaint.color = ContextCompat.getColor(context, color) - } - - companion object { - private const val INDEX_OF_LARGE_BAR = 8 - private const val VERTICAL_BARS = - (INDEX_OF_LARGE_BAR * INDEX_OF_LARGE_BAR) + 6 // add fixed bars size - private const val CURVE_BOTTOM_MARGIN = 8f - - } -} - -data class DataPoint(val amount: Float) \ No newline at end of file diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/filter/FilterDialogFragment.kt b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/filter/FilterDialogFragment.kt index d97de257..e59d4f2c 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/filter/FilterDialogFragment.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/ui/devices/filter/FilterDialogFragment.kt @@ -3,14 +3,18 @@ package de.seemoo.at_tracking_detection.ui.devices.filter import android.os.Bundle import android.transition.AutoTransition import android.transition.TransitionManager +import android.animation.ValueAnimator +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.core.util.Pair +import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip import com.google.android.material.datepicker.MaterialDatePicker import dagger.hilt.android.AndroidEntryPoint import de.seemoo.at_tracking_detection.R @@ -26,6 +30,7 @@ import de.seemoo.at_tracking_detection.ui.devices.filter.models.FavoriteFilter import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import kotlin.math.ceil @AndroidEntryPoint class FilterDialogFragment : Fragment() { @@ -34,6 +39,10 @@ class FilterDialogFragment : Fragment() { private var _binding: DialogFilterBinding? = null private val binding get() = _binding!! + private val baseTextEndPaddingByChipId = mutableMapOf() + private val baseTextStartPaddingByChipId = mutableMapOf() + private val currentChipIconResByChipId = mutableMapOf() + private var suppressDeviceTypeChipCallbacks = false override fun onCreateView( inflater: LayoutInflater, @@ -122,8 +131,13 @@ class FilterDialogFragment : Fragment() { val isChecked = activeDeviceTypeFilter.contains(device.deviceType) chip.filterDeviceTypeChip.isChecked = isChecked chip.filterDeviceTypeChip.id = (device.deviceType.toString() + ".chip").hashCode() + configureDeviceTypeChip(chip.filterDeviceTypeChip, isChecked) chip.filterDeviceTypeChip.setOnCheckedChangeListener { _, isChecked -> + applyDeviceTypeChipState(chip.filterDeviceTypeChip, isChecked, animate = true) + if (suppressDeviceTypeChipCallbacks) { + return@setOnCheckedChangeListener + } if (isChecked) { activeDeviceTypeFilter.add(device.deviceType) } else { @@ -132,23 +146,50 @@ class FilterDialogFragment : Fragment() { devicesViewModel.addOrRemoveFilter(activeDeviceTypeFilter) } chip.filterDeviceTypeChip.setOnLongClickListener { - // Exclusively select only this device type - activeDeviceTypeFilter.deviceTypes.clear() - activeDeviceTypeFilter.add(device.deviceType) - devicesViewModel.addOrRemoveFilter(activeDeviceTypeFilter) + val isOnlyOneSelected = activeDeviceTypeFilter.deviceTypes.size == 1 && + activeDeviceTypeFilter.contains(device.deviceType) + + if (isOnlyOneSelected) { + // If this is already the only selected type, select all types again + DeviceManager.devices.forEach { d -> + activeDeviceTypeFilter.add(d.deviceType) + } + } else { + // Exclusively select only this device type + activeDeviceTypeFilter.deviceTypes.clear() + activeDeviceTypeFilter.add(device.deviceType) + } - // Update all chip checked states in the chip group + // Update all chip checked states in the chip group in one pass. val chipGroup = binding.filterDeviceTypes + suppressDeviceTypeChipCallbacks = true for (i in 0 until chipGroup.childCount) { - val childChip = chipGroup.getChildAt(i) as? com.google.android.material.chip.Chip - childChip?.isChecked = childChip?.id == chip.filterDeviceTypeChip.id + val childChip = chipGroup.getChildAt(i) as? Chip + if (isOnlyOneSelected) { + if (childChip != null && !childChip.isChecked) { + childChip.isChecked = true + applyDeviceTypeChipState(childChip, isChecked = true, animate = true) + } + } else { + val shouldBeChecked = childChip?.id == chip.filterDeviceTypeChip.id + if (childChip != null && childChip.isChecked != shouldBeChecked) { + childChip.isChecked = shouldBeChecked + applyDeviceTypeChipState(childChip, shouldBeChecked, animate = true) + } + } } + suppressDeviceTypeChipCallbacks = false + devicesViewModel.addOrRemoveFilter(activeDeviceTypeFilter) true } binding.filterDeviceTypes.addView(chip.root) } // Set click listeners for filter chips + configureAnimatedFilterChip(binding.filterIgnoreChip) + configureAnimatedFilterChip(binding.filterNotifiedChip) + configureAnimatedFilterChip(binding.filterFavoriteChip) + binding.filterIgnoreChip.setOnClickListener { devicesViewModel.cycleIgnoredFilterState() } @@ -160,14 +201,23 @@ class FilterDialogFragment : Fragment() { } // Observe filter states to update chip appearance + devicesViewModel.ignoredFilterState.value?.let { state -> + updateChipAppearance(binding.filterIgnoreChip, state, animate = false) + } devicesViewModel.ignoredFilterState.observe(viewLifecycleOwner) { state -> - updateChipAppearance(binding.filterIgnoreChip, state) + updateChipAppearance(binding.filterIgnoreChip, state, animate = true) + } + devicesViewModel.notifiedFilterState.value?.let { state -> + updateChipAppearance(binding.filterNotifiedChip, state, animate = false) } devicesViewModel.notifiedFilterState.observe(viewLifecycleOwner) { state -> - updateChipAppearance(binding.filterNotifiedChip, state) + updateChipAppearance(binding.filterNotifiedChip, state, animate = true) + } + devicesViewModel.favoriteFilterState.value?.let { state -> + updateChipAppearance(binding.filterFavoriteChip, state, animate = false) } devicesViewModel.favoriteFilterState.observe(viewLifecycleOwner) { state -> - updateChipAppearance(binding.filterFavoriteChip, state) + updateChipAppearance(binding.filterFavoriteChip, state, animate = true) } binding.filterDateRangeInput.setOnClickListener { @@ -227,21 +277,175 @@ class FilterDialogFragment : Fragment() { private fun toLocalDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate() - private fun updateChipAppearance(chip: com.google.android.material.chip.Chip, state: DevicesViewModel.FilterState) { + private fun configureDeviceTypeChip(chip: Chip, isChecked: Boolean) { + configureAnimatedFilterChip(chip) + applyChipVisualState( + chip = chip, + targetIconRes = if (isChecked) R.drawable.ic_baseline_check_24 else null, + animate = false + ) + } + + private fun configureAnimatedFilterChip(chip: Chip) { + chip.gravity = Gravity.CENTER + chip.textAlignment = View.TEXT_ALIGNMENT_CENTER + chip.chipIcon = chip.chipIcon?.mutate() + baseTextStartPaddingByChipId[chip.id] = chip.textStartPadding + baseTextEndPaddingByChipId[chip.id] = chip.textEndPadding + currentChipIconResByChipId[chip.id] = R.drawable.ic_baseline_check_24 + chip.post { + val stableWidth = calculateStableChipWidth(chip) + chip.minWidth = stableWidth + chip.layoutParams = chip.layoutParams.apply { + width = stableWidth + } + } + } + + private fun applyDeviceTypeChipState(chip: Chip, isChecked: Boolean, animate: Boolean) { + applyChipVisualState( + chip = chip, + targetIconRes = if (isChecked) R.drawable.ic_baseline_check_24 else null, + animate = animate + ) + } + + private fun applyChipVisualState(chip: Chip, targetIconRes: Int?, animate: Boolean) { + val baseTextStartPadding = baseTextStartPaddingByChipId[chip.id] ?: chip.textStartPadding + val baseTextEndPadding = baseTextEndPaddingByChipId[chip.id] ?: chip.textEndPadding + val iconSpace = getChipIconSpace(chip) + val hasIcon = targetIconRes != null + // Checked: center in the space right of the icon. Unchecked: center across full chip width. + val targetTextStartPadding: Float + val targetTextEndPadding: Float + if (hasIcon) { + targetTextStartPadding = baseTextStartPadding + targetTextEndPadding = baseTextEndPadding + } else { + // Shift text left by half icon space while keeping start+end padding sum stable. + val desiredShift = iconSpace / 2f + val newStartPadding = (baseTextStartPadding - desiredShift).coerceAtLeast(0f) + val appliedShift = baseTextStartPadding - newStartPadding + targetTextStartPadding = newStartPadding + targetTextEndPadding = baseTextEndPadding + appliedShift + } + + if (animate) { + chip.animate().cancel() + chip.animate().alpha(0.7f).setDuration(CHIP_STATE_ANIMATION_DURATION_MS / 2).withEndAction { + chip.animate().alpha(1f).setDuration(CHIP_STATE_ANIMATION_DURATION_MS / 2).start() + }.start() + + ValueAnimator.ofFloat(chip.textStartPadding, targetTextStartPadding).apply { + duration = CHIP_STATE_ANIMATION_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { animator -> + chip.textStartPadding = animator.animatedValue as Float + } + }.start() + + ValueAnimator.ofFloat(chip.textEndPadding, targetTextEndPadding).apply { + duration = CHIP_STATE_ANIMATION_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { animator -> + chip.textEndPadding = animator.animatedValue as Float + } + }.start() + } else { + chip.textStartPadding = targetTextStartPadding + chip.textEndPadding = targetTextEndPadding + } + + animateChipIcon(chip, targetIconRes, animate) + } + + private fun animateChipIcon(chip: Chip, targetIconRes: Int?, animate: Boolean) { + val currentIconRes = currentChipIconResByChipId[chip.id] + val targetIconAlpha = if (targetIconRes != null) 255 else 0 + + if (!animate) { + if (targetIconRes != null && currentIconRes != targetIconRes) { + chip.setChipIconResource(targetIconRes) + chip.chipIcon = chip.chipIcon?.mutate() + } + chip.chipIcon?.alpha = targetIconAlpha + currentChipIconResByChipId[chip.id] = targetIconRes + return + } + + if (currentIconRes != null && targetIconRes != null && currentIconRes != targetIconRes) { + var hasSwappedIcon = false + ValueAnimator.ofFloat(0f, 1f).apply { + duration = CHIP_STATE_ANIMATION_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { animator -> + val fraction = animator.animatedFraction + if (fraction < 0.5f) { + chip.chipIcon?.alpha = ((1f - (fraction * 2f)) * 255f).toInt() + } else { + if (!hasSwappedIcon) { + chip.setChipIconResource(targetIconRes) + chip.chipIcon = chip.chipIcon?.mutate() + hasSwappedIcon = true + } + chip.chipIcon?.alpha = (((fraction - 0.5f) * 2f) * 255f).toInt() + } + } + start() + } + currentChipIconResByChipId[chip.id] = targetIconRes + return + } + + if (targetIconRes != null && currentIconRes != targetIconRes) { + chip.setChipIconResource(targetIconRes) + chip.chipIcon = chip.chipIcon?.mutate() + chip.chipIcon?.alpha = 0 + } + + ValueAnimator.ofInt(chip.chipIcon?.alpha ?: targetIconAlpha, targetIconAlpha).apply { + duration = CHIP_STATE_ANIMATION_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { animator -> + chip.chipIcon?.alpha = animator.animatedValue as Int + } + start() + } + currentChipIconResByChipId[chip.id] = targetIconRes + } + + private fun calculateStableChipWidth(chip: Chip): Int { + val baseTextStartPadding = baseTextStartPaddingByChipId[chip.id] ?: chip.textStartPadding + val baseTextEndPadding = baseTextEndPaddingByChipId[chip.id] ?: chip.textEndPadding + val iconSpace = getChipIconSpace(chip) + val textWidth = chip.paint.measureText(chip.text?.toString().orEmpty()) + + val checkedWidth = + chip.chipStartPadding + iconSpace + baseTextStartPadding + textWidth + baseTextEndPadding + chip.chipEndPadding + return ceil(checkedWidth.toDouble()).toInt() + } + + private fun getChipIconSpace(chip: Chip): Float { + val iconWidth = chip.chipIcon?.intrinsicWidth?.takeIf { it > 0 }?.toFloat() + ?: 0f + return iconWidth + chip.iconStartPadding + chip.iconEndPadding + } + + private fun updateChipAppearance(chip: Chip, state: DevicesViewModel.FilterState, animate: Boolean) { when (state) { DevicesViewModel.FilterState.UNSELECTED -> { chip.isChecked = false - chip.checkedIcon = null + applyChipVisualState(chip, targetIconRes = null, animate = animate) chip.paintFlags = chip.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv() } DevicesViewModel.FilterState.INCLUDING -> { chip.isChecked = true - chip.setCheckedIconResource(R.drawable.ic_baseline_check_24) + applyChipVisualState(chip, targetIconRes = R.drawable.ic_baseline_check_24, animate = animate) chip.paintFlags = chip.paintFlags and android.graphics.Paint.STRIKE_THRU_TEXT_FLAG.inv() } DevicesViewModel.FilterState.EXCLUDING -> { chip.isChecked = true - chip.setCheckedIconResource(R.drawable.ic_baseline_close_24) + applyChipVisualState(chip, targetIconRes = R.drawable.ic_baseline_close_24, animate = animate) chip.paintFlags = chip.paintFlags or android.graphics.Paint.STRIKE_THRU_TEXT_FLAG } } @@ -253,6 +457,7 @@ class FilterDialogFragment : Fragment() { } companion object { + private const val CHIP_STATE_ANIMATION_DURATION_MS = 150L private const val DATE_RANGE_PICKER_TAG = "de.seemoo.at_tracking_detection.DATE_RANGE_PICKER" } diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/ScanOrchestrator.kt b/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/ScanOrchestrator.kt index 5527327f..87b17110 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/ScanOrchestrator.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/util/ble/ScanOrchestrator.kt @@ -33,7 +33,7 @@ object ScanOrchestrator { appContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager } - private val handlerThread = HandlerThread("BleScanOrchestrator") + private val handlerThread = HandlerThread("BleScanOrchestrator").apply { isDaemon = true } private val handler: Handler @Volatile private var currentCallback: ScanCallback? = null diff --git a/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt b/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt index eb8fb7de..3caedfbd 100644 --- a/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt +++ b/app/src/main/java/de/seemoo/at_tracking_detection/worker/ScheduleWorkersReceiver.kt @@ -50,7 +50,7 @@ class ScheduleWorkersReceiver: BroadcastReceiver() { } finally { BackgroundWorkScheduler.scheduleAlarmWakeupIfScansFail() } - }.start() + }.apply { isDaemon = true }.start() } // Our exact/alarm fired to perform a scan: enqueue work, don't run the scan inline. @@ -64,7 +64,7 @@ class ScheduleWorkersReceiver: BroadcastReceiver() { } catch (t: Throwable) { Timber.w(t, "Failed to enqueue immediate scan from receiver") } - }.start() + }.apply { isDaemon = true }.start() } // System broadcasts: keep receiver fast; just (re)establish lightweight schedules. diff --git a/app/src/main/res/layout/dialog_filter.xml b/app/src/main/res/layout/dialog_filter.xml index 2c876eba..ada19779 100644 --- a/app/src/main/res/layout/dialog_filter.xml +++ b/app/src/main/res/layout/dialog_filter.xml @@ -119,6 +119,11 @@ style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:gravity="center" + android:textAlignment="center" + app:checkedIconVisible="false" + app:chipIcon="@drawable/ic_baseline_check_24" + app:chipIconVisible="true" android:text="@string/filter_ignored" /> diff --git a/app/src/main/res/layout/include_filter_chip.xml b/app/src/main/res/layout/include_filter_chip.xml index 8d7f487e..60e748e5 100644 --- a/app/src/main/res/layout/include_filter_chip.xml +++ b/app/src/main/res/layout/include_filter_chip.xml @@ -1,5 +1,6 @@ - + @@ -13,5 +14,10 @@ style="@style/Widget.Material3.Chip.Filter" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:gravity="center" + android:textAlignment="center" + app:checkedIconVisible="false" + app:chipIcon="@drawable/ic_baseline_check_24" + app:chipIconVisible="true" android:text="@{text}" /> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9c23c750..7ffbeb0b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -53,13 +53,13 @@ Fahrrad Woanders Tracker nicht gefunden - Feedback wurde übermittel + Feedback wurde übermittelt Schließen Ton wird abgespielt… Berechtigung erforderlich Die Berechtigung muss erteilt werden, damit man die App verwenden kann. RSSI - Zuletzt gesehen + Zuletzt: Statistikdaten teilen? Statistikdaten teilen diff --git a/build.gradle b/build.gradle index 0f0aad6f..eb789311 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { ext.room_version = '2.8.4' ext.compose_version = '1.7.3' ext.about_libraries_version = '13.2.1' - ext.work_version = '2.11.1' + ext.work_version = '2.11.2' ext.ksp_version = '2.3.6' repositories { diff --git a/fastlane/metadata/android/de-DE/changelogs/64.txt b/fastlane/metadata/android/de-DE/changelogs/64.txt new file mode 100644 index 00000000..db0ef2d2 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/64.txt @@ -0,0 +1,4 @@ +VERBESSERT: Visuelle Fehler im Filterdialog behoben +VERBESSERT: Verhalten vom Hintergrundscan optimiert +VERBESSERT: Übersetzungen verbessert +VERBESSERT: allgemeine Fehlerbehebungen \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/64.txt b/fastlane/metadata/android/en-US/changelogs/64.txt new file mode 100644 index 00000000..2e812821 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/64.txt @@ -0,0 +1,4 @@ +IMPROVED: Fix Visual Bugs in Filter Dialog +IMPROVED: Optimize Background scanning behavior +IMPROVED: Improve translations +IMPROVED: Bugfixes \ No newline at end of file