diff --git a/.gitignore b/.gitignore
index 4029285..fcbccd7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ build/
# Local configuration file (sdk path, etc)
local.properties
secrets.properties
+signing.properties
# Log/OS Files
*.log
@@ -13,6 +14,7 @@ captures/
.externalNativeBuild/
.cxx/
*.apk
+*.aab
output.json
# IntelliJ
@@ -25,6 +27,7 @@ render.experimental.xml
# Keystore files
*.jks
*.keystore
+navi-keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7572717..dfcbbfa 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,20 +1,75 @@
+import java.util.Properties
+import org.gradle.api.GradleException
+
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
- id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
- id("kotlin-kapt")
+ id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
- id("kotlinx-serialization")
- id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
- id("com.apollographql.apollo") version "4.3.3"
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.apollographql.apollo")
+ id("com.google.devtools.ksp")
+}
+
+val secretsPropertiesFile = rootProject.file("secrets.properties")
+val secretsProperties = Properties().apply {
+ if (!secretsPropertiesFile.exists()) {
+ throw GradleException("secrets.properties not found at ${secretsPropertiesFile.absolutePath}. " +
+ "Please create this file with the required API keys and configuration values. " +
+ "Refer to the project README or CI configuration for required properties.")
+ }
+ secretsPropertiesFile.inputStream().use(::load)
}
-secrets {
- propertiesFileName = "secrets.properties"
+val signingPropertiesFile = rootProject.file("signing.properties")
+val signingProperties = Properties()
+if (signingPropertiesFile.exists()) {
+ signingPropertiesFile.inputStream().use(signingProperties::load)
}
-hilt {
- enableExperimentalClasspathAggregation = true
+fun Properties.requireString(key: String): String =
+ getProperty(key) ?: error("Missing required property '$key' in properties file.")
+
+fun String.unquoteIfWrapped(): String {
+ val trimmed = trim()
+ return if (trimmed.length >= 2 && trimmed.first() == '"' && trimmed.last() == '"') {
+ trimmed.substring(1, trimmed.length - 1)
+ } else {
+ trimmed
+ }
+}
+
+fun asBuildConfigString(value: String): String {
+ val normalized = value.unquoteIfWrapped()
+ return "\"${normalized.replace("\\", "\\\\").replace("\"", "\\\"")}\""
+}
+
+fun asManifestPlaceholder(value: String): String =
+ value.unquoteIfWrapped()
+
+fun configureUrlsForVariant(
+ prefix: String,
+ block: Any
+) {
+ val buildTypeBlock = block as com.android.build.gradle.internal.dsl.BuildType
+ buildTypeBlock.buildConfigField(
+ "String",
+ "BACKEND_URL",
+ asBuildConfigString(secretsProperties.requireString("${prefix}_BACKEND_URL")),
+ )
+ buildTypeBlock.manifestPlaceholders["BACKEND_URL"] = asManifestPlaceholder(
+ secretsProperties.requireString("${prefix}_BACKEND_URL")
+ )
+ buildTypeBlock.buildConfigField(
+ "String",
+ "EATERY_URL",
+ asBuildConfigString(secretsProperties.requireString("${prefix}_EATERY_URL")),
+ )
+ buildTypeBlock.buildConfigField(
+ "String",
+ "UPLIFT_URL",
+ asBuildConfigString(secretsProperties.requireString("${prefix}_UPLIFT_URL")),
+ )
}
android {
@@ -25,129 +80,171 @@ android {
applicationId = "com.cornellappdev.transit"
minSdk = 26
targetSdk = 36
- versionCode = 11
- versionName = "2.1"
+ versionCode = 12
+ versionName = "2.1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
+
+ buildConfigField(
+ "String",
+ "MAPS_KEY",
+ asBuildConfigString(secretsProperties.requireString("MAPS_KEY")),
+ )
+ manifestPlaceholders["MAPS_KEY"] = asManifestPlaceholder(secretsProperties.requireString("MAPS_KEY"))
+ }
+
+ signingConfigs {
+ create("release") {
+ if (signingProperties.isNotEmpty()) {
+ storeFile = rootProject.file(signingProperties.requireString("KEYSTORE_PATH"))
+ storePassword = signingProperties.requireString("KEYSTORE_PASSWORD")
+ keyAlias = signingProperties.requireString("KEY_ALIAS")
+ keyPassword = signingProperties.requireString("KEY_PASSWORD")
+ }
+ }
}
buildTypes {
+ debug {
+ configureUrlsForVariant("DEBUG", this)
+ buildConfigField("boolean", "ECOSYSTEM_FLAG", "true")
+ }
create("ecosystem") {
initWith(getByName("debug"))
isDebuggable = true
+ configureUrlsForVariant("DEBUG", this)
buildConfigField("boolean", "ECOSYSTEM_FLAG", "true")
signingConfig = signingConfigs.getByName("debug")
}
- debug {
- buildConfigField("boolean", "ECOSYSTEM_FLAG", "true")
- }
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ optimization {
+ baselineProfile {
+ ignoreFromAllExternalDependencies = true
+ }
+ }
+ configureUrlsForVariant("PROD", this)
buildConfigField("boolean", "ECOSYSTEM_FLAG", "true")
- signingConfig = signingConfigs.getByName("debug")
+ if (signingProperties.isNotEmpty()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
}
}
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
+
kotlinOptions {
jvmTarget = "1.8"
}
+
buildFeatures {
+ buildConfig = true
compose = true
}
+
composeOptions {
kotlinCompilerExtensionVersion = "1.5.2"
}
+
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ // Work around device/OS install failures with embedded baseline profiles.
+ excludes += "assets/dexopt/baseline.prof"
+ excludes += "assets/dexopt/baseline.profm"
}
}
}
dependencies {
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2024.11.00"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+
+ // Android Core
implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
- implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.0")
+ implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0")
+
+ // Compose
implementation(platform("androidx.compose:compose-bom:2024.11.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material:1.7.5")
- implementation("androidx.compose.material3:material3")
- implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.compose.material3:material3:1.3.1")
- implementation("androidx.constraintlayout:constraintlayout:2.2.0")
- implementation("androidx.media3:media3-common:1.3.0")
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.2.1")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- androidTestImplementation(platform("androidx.compose:compose-bom:2024.11.00"))
- androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ implementation("androidx.activity:activity-compose:1.9.3")
debugImplementation("androidx.compose.ui:ui-tooling")
"ecosystemImplementation"("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
- implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0")
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
-
- // Images
- implementation("io.coil-kt.coil3:coil-compose:3.0.4")
- implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
- implementation("com.valentinilk.shimmer:compose-shimmer:1.3.1")
-
- //Maps
- implementation("com.google.maps.android:maps-compose:4.0.0")
- implementation("com.google.android.gms:play-services-maps:19.0.0")
-
- //Accompanist Permissions
- implementation("com.google.accompanist:accompanist-permissions:0.34.0")
+ // Lifecycle & Navigation
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.4")
+ implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
- // OkHTTP
+ // Networking
implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0"))
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:logging-interceptor")
-
- // Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.14.0")
- //Dagger Hilt
- implementation("com.google.dagger:hilt-android:2.50")
- kapt("com.google.dagger:hilt-android-compiler:2.50")
+ // Images & Media
+ implementation("io.coil-kt.coil3:coil-compose:3.0.4")
+ implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
+ implementation("com.valentinilk.shimmer:compose-shimmer:1.3.1")
+ implementation("androidx.media3:media3-common:1.3.0")
- //Navigation
- implementation("androidx.hilt:hilt-navigation-fragment:1.2.0")
- implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
- implementation("androidx.navigation:navigation-compose:2.8.4")
+ // Maps
+ implementation("com.google.maps.android:maps-compose:4.0.0")
+ implementation("com.google.android.gms:play-services-maps:19.0.0")
+ implementation("com.google.android.gms:play-services-location:21.3.0")
- //Datastore
+ // Permissions
+ implementation("com.google.accompanist:accompanist-permissions:0.34.0")
+
+ // Data Storage
implementation("androidx.datastore:datastore-preferences:1.1.1")
- //Datetime
+ // DateTime
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.3.0")
- //Bottomsheet
- implementation("io.morfly.compose:advanced-bottomsheet-material3:0.1.0")
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // Serialization
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
- //Apollo GraphQL
+ // Dependency Injection
+ implementation("com.google.dagger:hilt-android:2.50")
+ ksp("com.google.dagger:hilt-android-compiler:2.50")
+ implementation("androidx.hilt:hilt-navigation-fragment:1.2.0")
+
+ // Apollo GraphQL
implementation("com.apollographql.apollo:apollo-runtime:4.3.3")
+ // Bottom Sheet
+ implementation("io.morfly.compose:advanced-bottomsheet-material3:0.1.0")
}
apollo {
diff --git a/app/release/app-release.aab b/app/release/app-release.aab
deleted file mode 100644
index 08ed0f1..0000000
Binary files a/app/release/app-release.aab and /dev/null differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1d4e1a9..f0edd61 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,6 +40,9 @@
+
diff --git a/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt
index e5995f2..f6c096a 100644
--- a/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt
+++ b/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt
@@ -190,6 +190,27 @@ object TimeUtils {
return OpenStatus(false, "Closed today")
}
+ private data class DateTimeInterval(
+ val start: LocalDateTime,
+ val end: LocalDateTime,
+ )
+
+ private fun buildIntervalsForDate(
+ date: LocalDate,
+ hours: List,
+ overnightOnly: Boolean = false,
+ ): List {
+ return hours.mapNotNull { timeString ->
+ val (startTime, endTime) = parseTimeRange(timeString) ?: return@mapNotNull null
+ val isOvernight = !endTime.isAfter(startTime) // includes end == start
+ if (overnightOnly && !isOvernight) return@mapNotNull null
+
+ val start = date.atTime(startTime)
+ val end = if (isOvernight) date.plusDays(1).atTime(endTime) else date.atTime(endTime)
+ DateTimeInterval(start = start, end = end)
+ }
+ }
+
/**
* Given operating hours, return whether it is open and when it is open until
* or when it will next open
@@ -205,31 +226,44 @@ object TimeUtils {
return OpenStatus(false, "Closed today")
}
- val rotatedOperatingHours = rotateOperatingHours(operatingHours)
+ val rotatedOperatingHours =
+ rotateOperatingHours(operatingHours, currentDateTime.toLocalDate())
- val currentTime = currentDateTime.toLocalTime()
val todaySchedule =
rotatedOperatingHours[0].hours // First day should be today after rotation
- // Check if closed today
- if (todaySchedule.any { it.equals("Closed", ignoreCase = true) }) {
- return findOpenNextDay(rotatedOperatingHours)
- }
+ val todayDate = currentDateTime.toLocalDate()
+ val yesterdayDate = todayDate.minusDays(1)
- val timeRanges = todaySchedule.mapNotNull { parseTimeRange(it) }
+ val todayIntervals = buildIntervalsForDate(todayDate, todaySchedule)
+ val yesterdayOvernightIntervals = buildIntervalsForDate(
+ date = yesterdayDate,
+ hours = rotatedOperatingHours.last().hours,
+ overnightOnly = true
+ )
- // Check if currently open
- for (range in timeRanges) {
- if (currentTime >= range.first && currentTime < range.second) {
- return OpenStatus(true, "until ${formatTime(range.second)}")
+ // Check if currently within an open interval (including yesterday's overnight intervals)
+ val currentInterval = (yesterdayOvernightIntervals + todayIntervals)
+ .firstOrNull { interval ->
+ currentDateTime >= interval.start && currentDateTime < interval.end
}
+
+ if (currentInterval != null) {
+ return OpenStatus(true, "until ${formatTime(currentInterval.end.toLocalTime())}")
+ }
+
+ // Check if closed today but not within an overnight interval
+ if (todaySchedule.any { it.equals("Closed", ignoreCase = true) }) {
+ return findOpenNextDay(rotatedOperatingHours)
}
// Check if opens later today
- for (range in timeRanges) {
- if (currentTime < range.first) {
- return OpenStatus(false, "until ${formatTime(range.first)}")
- }
+ val nextStartToday = todayIntervals
+ .map { it.start }
+ .filter { it > currentDateTime }
+ .minOrNull()
+ if (nextStartToday != null) {
+ return OpenStatus(false, "until ${formatTime(nextStartToday.toLocalTime())}")
}
// Closed for today, find next open day
diff --git a/build.gradle.kts b/build.gradle.kts
index 679379f..578d97d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,6 +11,9 @@ buildscript {
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
+ id("com.apollographql.apollo") version "4.3.3" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "1.6.21"
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index a3ad8ba..9f45966 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,4 +22,10 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-android.defaults.buildfeatures.buildconfig=true
\ No newline at end of file
+android.defaults.buildfeatures.buildconfig=true
+
+# Gradle JDK Configuration
+# To configure a specific JDK, set this in your user-level ~/.gradle/gradle.properties
+# or use gradle toolchains (recommended). Do not commit machine-specific paths to version control.
+# Example:
+# org.gradle.java.home=/path/to/jdk