Skip to content

Commit 2cac948

Browse files
authored
Merge pull request #5 from StitchMl/android
Release 6.1.0: notification filters and settings
2 parents fd47985 + 194f2f5 commit 2cac948

15 files changed

Lines changed: 870 additions & 212 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Build Release Artifacts
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
android:
8+
runs-on: ubuntu-latest
9+
timeout-minutes: 30
10+
steps:
11+
- uses: actions/checkout@v5
12+
- uses: actions/setup-java@v5
13+
with:
14+
distribution: temurin
15+
java-version: "17"
16+
- name: Build release APK
17+
run: |
18+
chmod +x ./gradlew
19+
./gradlew test lint assembleRelease
20+
- name: Sign and verify APK
21+
env:
22+
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
23+
STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
24+
KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
25+
run: |
26+
set -euo pipefail
27+
test -n "$KEYSTORE_BASE64"
28+
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.keystore"
29+
KEY_ALIAS="$(keytool -list -v -keystore "$RUNNER_TEMP/release.keystore" -storepass "$STORE_PASSWORD" | sed -n 's/^Alias name: //p' | head -n 1)"
30+
test -n "$KEY_ALIAS"
31+
BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
32+
"$BUILD_TOOLS/zipalign" -f -p 4 app/build/outputs/apk/release/app-release-unsigned.apk build/ScoutEventi-Android-v6.1.0-aligned.apk
33+
"$BUILD_TOOLS/apksigner" sign \
34+
--ks "$RUNNER_TEMP/release.keystore" \
35+
--ks-key-alias "$KEY_ALIAS" \
36+
--ks-pass "pass:$STORE_PASSWORD" \
37+
--key-pass "pass:$KEY_PASSWORD" \
38+
--out build/ScoutEventi-Android-v6.1.0.apk \
39+
build/ScoutEventi-Android-v6.1.0-aligned.apk
40+
"$BUILD_TOOLS/apksigner" verify --verbose --print-certs build/ScoutEventi-Android-v6.1.0.apk
41+
sha256sum build/ScoutEventi-Android-v6.1.0.apk > build/ScoutEventi-Android-v6.1.0.sha256
42+
rm -f build/ScoutEventi-Android-v6.1.0-aligned.apk "$RUNNER_TEMP/release.keystore"
43+
- uses: actions/upload-artifact@v6
44+
with:
45+
name: android-release-v6.1.0
46+
if-no-files-found: error
47+
path: |
48+
build/ScoutEventi-Android-v6.1.0.apk
49+
build/ScoutEventi-Android-v6.1.0.sha256
50+
51+
ios:
52+
runs-on: macos-15
53+
timeout-minutes: 40
54+
steps:
55+
- uses: actions/checkout@v5
56+
- uses: actions/setup-java@v5
57+
with:
58+
distribution: temurin
59+
java-version: "17"
60+
- name: Build unsigned iOS app
61+
run: |
62+
set -euo pipefail
63+
chmod +x ./gradlew
64+
mkdir -p build
65+
xcodebuild \
66+
-project iosApp/iosApp.xcodeproj \
67+
-scheme iosApp \
68+
-configuration Release \
69+
-sdk iphoneos \
70+
-derivedDataPath build/ios-release \
71+
-destination 'generic/platform=iOS' \
72+
CODE_SIGNING_ALLOWED=NO \
73+
CODE_SIGNING_REQUIRED=NO \
74+
CODE_SIGN_IDENTITY='' \
75+
DEVELOPMENT_TEAM='' \
76+
MARKETING_VERSION=6.1.0 \
77+
APP_MARKETING_VERSION=6.1.0 \
78+
CURRENT_PROJECT_VERSION=7 \
79+
APP_BUILD_NUMBER=7 \
80+
clean build | tee build/ios-release.log
81+
- name: Package and verify unsigned IPA
82+
run: |
83+
set -euo pipefail
84+
APP_PATH="$(find build/ios-release/Build/Products -maxdepth 2 -type d -name '*.app' | head -n 1)"
85+
test -n "$APP_PATH"
86+
mkdir -p build/ios-package/Payload
87+
ditto "$APP_PATH" "build/ios-package/Payload/$(basename "$APP_PATH")"
88+
(cd build/ios-package && zip -qry ../ScoutEventi-iOS-unsigned-v6.1.0.ipa Payload)
89+
unzip -t build/ScoutEventi-iOS-unsigned-v6.1.0.ipa
90+
shasum -a 256 build/ScoutEventi-iOS-unsigned-v6.1.0.ipa > build/ScoutEventi-iOS-unsigned-v6.1.0.sha256
91+
- uses: actions/upload-artifact@v6
92+
with:
93+
name: ios-release-v6.1.0
94+
if-no-files-found: error
95+
path: |
96+
build/ScoutEventi-iOS-unsigned-v6.1.0.ipa
97+
build/ScoutEventi-iOS-unsigned-v6.1.0.sha256
98+
build/ios-release.log

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ configure<ApplicationExtension> {
1616
minSdk = 26
1717
//noinspection OldTargetApi
1818
targetSdk = 36
19-
versionCode = 6
20-
versionName = "6.0.0"
19+
versionCode = 7
20+
versionName = "6.1.0"
2121
vectorDrawables { useSupportLibrary = true }
2222
}
2323

app/src/androidTest/java/it/buonacaccia/app/ExampleInstrumentedTest.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ package it.buonacaccia.app
22

33
import androidx.test.platform.app.InstrumentationRegistry
44
import androidx.test.ext.junit.runners.AndroidJUnit4
5-
5+
import org.junit.Assert.assertEquals
66
import org.junit.Test
77
import org.junit.runner.RunWith
88

9-
import org.junit.Assert.*
10-
119
/**
1210
* Instrumented test, which will execute on an Android device.
1311
*
@@ -19,6 +17,6 @@ class ExampleInstrumentedTest {
1917
fun useAppContext() {
2018
// Context of the app under test.
2119
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22-
assertEquals("it.buonacaccia.app", appContext.packageName)
20+
assertEquals(BuildConfig.APPLICATION_ID, appContext.packageName)
2321
}
24-
}
22+
}

app/src/main/java/it/buonacaccia/app/background/NewEventsWorker.kt

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import it.buonacaccia.app.data.BuonaCacciaRegions
1111
import it.buonacaccia.app.data.EventStore
1212
import it.buonacaccia.app.data.EventsRepository
1313
import it.buonacaccia.app.data.FetchSafety
14-
import it.buonacaccia.app.data.NotificationPreferences
1514
import it.buonacaccia.app.data.NotificationRuleEngine
1615
import it.buonacaccia.app.data.shouldEnrichRegistrationWindow
1716
import it.buonacaccia.app.notify.Notifier
@@ -36,18 +35,7 @@ class NewEventsWorker(
3635
val cachedSnapshot = EventStore.cachedEventsFlow(applicationContext).first()
3736
val seenKeysBefore = EventStore.seenIdsFlow(applicationContext).first()
3837
val minimumExpectedCount = FetchSafety.minimumExpectedCountForFullDataset(cachedSnapshot.size)
39-
val interestedTypes = EventStore.notifyTypesFlow(applicationContext).first()
40-
val interestedRegions = EventStore.notifyRegionsFlow(applicationContext).first()
41-
val interestedZones = EventStore.notifyZonesFlow(applicationContext).first()
42-
val mutedTypes = EventStore.muteTypesFlow(applicationContext).first()
43-
val typeRegionRules = EventStore.notifyTypeRegionRulesFlow(applicationContext).first()
44-
val notificationPreferences = NotificationPreferences(
45-
mutedTypes = mutedTypes,
46-
allowedTypes = interestedTypes,
47-
defaultRegions = interestedRegions,
48-
defaultZones = interestedZones,
49-
typeRegionRules = typeRegionRules
50-
)
38+
val notificationPreferences = EventStore.notificationPreferences(applicationContext)
5139
val prefilterRegions = NotificationRuleEngine.regionsForFetchPrefilter(notificationPreferences) ?: emptySet()
5240
val filtersByRegion = prefilterRegions.associateWith { BuonaCacciaRegions.filterOf(it) }
5341
val filters = filtersByRegion.values.filterNotNull()

app/src/main/java/it/buonacaccia/app/background/SubscriptionsWorker.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import it.buonacaccia.app.data.BuonaCacciaScopes
1111
import it.buonacaccia.app.data.EventStore
1212
import it.buonacaccia.app.data.EventsRepository
1313
import it.buonacaccia.app.data.FetchSafety
14+
import it.buonacaccia.app.data.NotificationRuleEngine
1415
import it.buonacaccia.app.notify.Notifier
1516
import kotlinx.coroutines.CancellationException
1617
import kotlinx.coroutines.Dispatchers
@@ -91,11 +92,13 @@ class SubscriptionsWorker(
9192
EventStore.purgeClosed(applicationContext, LocalDate.now())
9293

9394
val events = EventStore.cachedEventsFlow(applicationContext).first()
95+
val notificationPreferences = EventStore.notificationPreferences(applicationContext)
9496
val sent = EventStore.sentRemindersFlow(applicationContext).first().toMutableSet()
9597
val newReminderKeys = mutableSetOf<String>()
9698

9799
val toRemind = events.filter { ev ->
98-
EventStore.eventKeyOf(ev) in subscribed
100+
EventStore.eventKeyOf(ev) in subscribed &&
101+
NotificationRuleEngine.shouldNotify(ev, notificationPreferences)
99102
}
100103

101104
val today = LocalDate.now()

app/src/main/java/it/buonacaccia/app/data/BcEvent.kt

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,64 @@ data class BcEvent(
1818
val branch: Branch? = null,
1919
val subsOpenDate: LocalDate? = null,
2020
val subsCloseDate: LocalDate? = null,
21-
val zone: String? = null
21+
val zone: String? = null,
2222
)
2323

2424
fun BcEvent.guessZone(): String? {
25-
zone?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
26-
val titleMatch = Regex(
27-
"\\b[Zz]ona\\s+([A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+(?:\\s+(?:di|dei|delle|della|del|da|in|sotto|d')\\s+[A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+)?(?:\\s+[A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+)*)"
28-
).find(title)
29-
titleMatch?.groupValues?.getOrNull(1)?.trim()?.let { return it }
30-
val loc = location ?: return null
31-
val match = Regex("\\(([^)]+)\\)").find(loc)
32-
val candidate = match?.groupValues?.getOrNull(1)?.trim() ?: return null
33-
if (candidate.length <= 2 && candidate.all { it.isUpperCase() || it.isLetter() }) {
34-
return null
35-
}
25+
ZoneCatalog.normalize(zone)?.let { return it }
26+
ZoneCatalog.fromTitle(title)?.let { return it }
27+
val locationValue = location ?: return null
28+
val parenthesized = Regex("\\(([^)]+)\\)")
29+
.find(locationValue)
30+
?.groupValues
31+
?.getOrNull(1)
32+
val candidate = ZoneCatalog.normalize(parenthesized) ?: return null
33+
if (candidate.length <= 2 && candidate.all { it.isUpperCase() || it.isLetter() }) return null
3634
return candidate
3735
}
36+
37+
object ZoneCatalog {
38+
private val separator = Regex("\\s+(?:[-\\u2013\\u2014|])\\s+|_")
39+
private val whitespace = Regex("\\s+")
40+
41+
fun normalize(raw: String?): String? {
42+
val cleaned = raw
43+
?.replace('\u00A0', ' ')
44+
?.replace(whitespace, " ")
45+
?.trim()
46+
?.replaceFirst(Regex("(?i)^zona\\s+"), "")
47+
?.split(separator, limit = 2)
48+
?.firstOrNull()
49+
?.trim(' ', ':', ';', ',', '.', '-', '(', ')')
50+
?.takeIf { it.length >= 3 }
51+
?: return null
52+
53+
if (cleaned.equals("da definire", ignoreCase = true) ||
54+
cleaned.equals("non definita", ignoreCase = true)
55+
) return null
56+
57+
val readableCase = if (cleaned.any(Char::isLetter) &&
58+
cleaned.filter(Char::isLetter).all(Char::isUpperCase)
59+
) {
60+
cleaned.lowercase().split(' ').joinToString(" ") { word ->
61+
word.replaceFirstChar(Char::titlecase)
62+
}
63+
} else {
64+
cleaned
65+
}
66+
val normalizedApostrophe = readableCase.replace(
67+
Regex("(?i)\\bd(['\\u2019])([a-zà-ÿ])"),
68+
) { match -> "d${match.groupValues[1]}${match.groupValues[2].uppercase()}" }
69+
70+
return normalizedApostrophe.replaceFirstChar {
71+
if (it.isLowerCase()) it.titlecase() else it.toString()
72+
}
73+
}
74+
75+
fun fromTitle(title: String): String? {
76+
val match = Regex(
77+
"(?i)\\bzona\\s+(.+?)(?=\\s+(?:[-\\u2013\\u2014|])\\s+|_|$)",
78+
).find(title) ?: return null
79+
return normalize(match.groupValues.getOrNull(1))
80+
}
81+
}

app/src/main/java/it/buonacaccia/app/data/EventStore.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import it.buonacaccia.app.widget.EventsByDateWidget
1212
import it.buonacaccia.app.widget.EventsWidgetKind
1313
import it.buonacaccia.app.widget.UpcomingOpeningsWidget
1414
import kotlinx.coroutines.flow.Flow
15+
import kotlinx.coroutines.flow.first
1516
import kotlinx.coroutines.flow.map
1617
import timber.log.Timber
1718
import java.net.URLDecoder
@@ -210,7 +211,6 @@ object EventStore {
210211
decodeNotifyTypeRegionRules(pref[KEY_NOTIFY_TYPE_REGION_RULES] ?: emptySet())
211212
}
212213

213-
@Suppress("unused")
214214
suspend fun setNotifyTypeRegionRules(ctx: Context, rules: Collection<NotificationTypeRegionRule>) {
215215
val normalizedRules = rules
216216
.mapNotNull(::normalizeNotifyTypeRegionRule)
@@ -221,6 +221,19 @@ object EventStore {
221221
Timber.d("EventStore.setNotifyTypeRegionRules count=%d", normalizedRules.size)
222222
}
223223

224+
suspend fun notificationPreferences(ctx: Context): NotificationPreferences {
225+
val pref = ctx.dataStore.data.first()
226+
return NotificationPreferences(
227+
mutedTypes = pref[KEY_MUTE_TYPES] ?: emptySet(),
228+
allowedTypes = pref[KEY_NOTIFY_TYPES] ?: emptySet(),
229+
defaultRegions = pref[KEY_NOTIFY_REGIONS] ?: emptySet(),
230+
defaultZones = pref[KEY_NOTIFY_ZONES] ?: emptySet(),
231+
typeRegionRules = decodeNotifyTypeRegionRules(
232+
pref[KEY_NOTIFY_TYPE_REGION_RULES] ?: emptySet()
233+
),
234+
)
235+
}
236+
224237
fun seenIdsFlow(ctx: Context): Flow<Set<String>> =
225238
ctx.dataStore.data.map { it[KEY_SEEN_IDS] ?: emptySet() }
226239

app/src/main/java/it/buonacaccia/app/data/HtmlParser.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,12 @@ object HtmlParser {
260260
var close = grab("MainContent_EventFormView_lbSubsTo")
261261
var seats = doc.selectFirst("#MainContent_EventFormView_lbSeats")?.text()?.trim()
262262
var taken = doc.selectFirst("#MainContent_EventFormView_lbTaken")?.text()?.trim()
263-
var zone = doc.selectFirst("#MainContent_EventFormView_lbZone")?.text()?.trim()?.ifBlank { null }
263+
var zone = ZoneCatalog.normalize(
264+
doc.selectFirst("#MainContent_EventFormView_lbZone")?.text()
265+
)
264266

265267
val text = doc.text()
268+
val structuredText = doc.wholeText()
266269

267270
if (open == null) {
268271
Regex("(?i)apriranno\\s+il\\s+(\\d{1,2}/\\d{1,2}/\\d{4})")
@@ -282,12 +285,12 @@ object HtmlParser {
282285
}
283286
if (zone == null) {
284287
Regex("(?i)zona:\\s*([^\\n\\r<|]+)")
285-
.find(text)?.groupValues?.getOrNull(1)?.let { zone = it.trim() }
288+
.find(structuredText)?.groupValues?.getOrNull(1)?.let {
289+
zone = ZoneCatalog.normalize(it)
290+
}
286291
}
287292
if (zone == null) {
288-
Regex(
289-
"\\b[Zz]ona\\s+([A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+(?:\\s+(?:di|dei|delle|della|del|da|in|sotto|d')\\s+[A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+)?(?:\\s+[A-Z\\u00C0-\\u00DC][a-zA-Z\\u00C0-\\u00FF']+)*)"
290-
).find(text)?.groupValues?.getOrNull(1)?.let { zone = it.trim() }
293+
zone = ZoneCatalog.fromTitle(text)
291294
}
292295

293296
if (open == null && close == null) {

0 commit comments

Comments
 (0)