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