Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-21 15:11:39 +01:00
parent d6b5d53060
commit d90a1dc8df
2145 changed files with 210227 additions and 2 deletions

5
app/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/build
/src/main/res/values-in/
/src/main/res/values-iw/
# /src/main/res/raw/ne_50m_admin_0_countries.json
locales_config.xml

408
app/build.gradle.kts Normal file
View file

@ -0,0 +1,408 @@
@file:Suppress("ChromeOsAbiSupport")
import breezy.buildlogic.getCommitCount
import breezy.buildlogic.getGitSha
import breezy.buildlogic.registerLocalesConfigTask
import java.util.Properties
plugins {
id("breezy.android.application")
id("breezy.android.application.compose")
id("com.android.application")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
kotlin("plugin.serialization")
id("com.mikepenz.aboutlibraries.plugin.android")
}
val supportedAbi = setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
android {
namespace = "org.breezyweather"
defaultConfig {
applicationId = "org.breezyweather"
versionCode = 60012
versionName = "6.0.12"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
multiDexEnabled = true
ndk {
abiFilters += supportedAbi
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
splits {
abi {
isEnable = true
reset()
include(*supportedAbi.toTypedArray())
isUniversalApk = true
}
}
buildTypes {
named("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-r${getCommitCount()}"
}
named("release") {
isShrinkResources = true
isMinifyEnabled = true
isDebuggable = false
isCrunchPngs = false // No need to do that, we already optimized them
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
val properties = Properties()
if (project.rootProject.file("local.properties").canRead()) {
properties.load(project.rootProject.file("local.properties").inputStream())
}
buildTypes.forEach {
it.buildConfigField(
"String",
"DEFAULT_LOCATION_SOURCE",
"\"${properties.getProperty("breezy.source.default_location") ?: "native"}\""
)
it.buildConfigField(
"String",
"DEFAULT_LOCATION_SEARCH_SOURCE",
"\"${properties.getProperty("breezy.source.default_location_search") ?: "openmeteo"}\""
)
it.buildConfigField(
"String",
"DEFAULT_GEOCODING_SOURCE",
"\"${properties.getProperty("breezy.source.default_geocoding") ?: "naturalearth"}\""
)
it.buildConfigField(
"String",
"DEFAULT_FORECAST_SOURCE",
"\"${properties.getProperty("breezy.source.default_weather") ?: "auto"}\""
)
it.buildConfigField(
"String",
"ACCU_WEATHER_KEY",
"\"${properties.getProperty("breezy.accu.key") ?: ""}\""
)
it.buildConfigField(
"String",
"AEMET_KEY",
"\"${properties.getProperty("breezy.aemet.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ATMO_AURA_KEY",
"\"${properties.getProperty("breezy.atmoaura.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ATMO_FRANCE_KEY",
"\"${properties.getProperty("breezy.atmofrance.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ATMO_GRAND_EST_KEY",
"\"${properties.getProperty("breezy.atmograndest.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ATMO_HDF_KEY",
"\"${properties.getProperty("breezy.atmohdf.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ATMO_SUD_KEY",
"\"${properties.getProperty("breezy.atmosud.key") ?: ""}\""
)
it.buildConfigField(
"String",
"BAIDU_IP_LOCATION_AK",
"\"${properties.getProperty("breezy.baiduip.key") ?: ""}\""
)
it.buildConfigField(
"String",
"BMKG_KEY",
"\"${properties.getProperty("breezy.bmkg.key") ?: ""}\""
)
it.buildConfigField(
"String",
"CWA_KEY",
"\"${properties.getProperty("breezy.cwa.key") ?: ""}\""
)
it.buildConfigField(
"String",
"ECCC_KEY",
"\"${properties.getProperty("breezy.eccc.key") ?: ""}\""
)
it.buildConfigField(
"String",
"GEO_NAMES_KEY",
"\"${properties.getProperty("breezy.geonames.key") ?: ""}\""
)
it.buildConfigField(
"String",
"MET_IE_KEY",
"\"${properties.getProperty("breezy.metie.key") ?: ""}\""
)
it.buildConfigField(
"String",
"MET_OFFICE_KEY",
"\"${properties.getProperty("breezy.metoffice.key") ?: ""}\""
)
it.buildConfigField(
"String",
"MF_WSFT_JWT_KEY",
"\"${properties.getProperty("breezy.mf.jwtKey") ?: ""}\""
)
it.buildConfigField(
"String",
"MF_WSFT_KEY",
"\"${properties.getProperty("breezy.mf.key") ?: ""}\""
)
it.buildConfigField(
"String",
"OPEN_WEATHER_KEY",
"\"${properties.getProperty("breezy.openweather.key") ?: ""}\""
)
it.buildConfigField(
"String",
"PIRATE_WEATHER_KEY",
"\"${properties.getProperty("breezy.pirateweather.key") ?: ""}\""
)
it.buildConfigField(
"String",
"POLLENINFO_KEY",
"\"${properties.getProperty("breezy.polleninfo.key") ?: ""}\""
)
}
flavorDimensions.add("default")
productFlavors {
create("basic") {
dimension = "default"
}
create("freenet") {
dimension = "default"
versionNameSuffix = "_freenet"
}
}
sourceSets {
getByName("basic") {
java.srcDirs("src/src_nonfreenet")
res.srcDirs("src/res_nonfreenet")
}
getByName("freenet") {
java.srcDirs("src/src_freenet")
res.srcDirs("src/res_freenet")
}
}
packaging {
resources.excludes.addAll(
listOf(
"kotlin-tooling-metadata.json",
"LICENSE.txt",
"META-INF/versions/9/OSGI-INF/MANIFEST.MF",
"META-INF/**/*.properties",
"META-INF/**/LICENSE.txt",
"META-INF/*.properties",
"META-INF/*.version",
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/NOTICE",
"META-INF/README.md"
)
)
}
dependenciesInfo {
includeInApk = false
}
buildFeatures {
viewBinding = true
buildConfig = true
// Disable some unused things
aidl = false
renderScript = false
shaders = false
}
lint {
abortOnError = false
checkReleaseBuilds = false
disable.addAll(listOf("MissingTranslation", "ExtraTranslation"))
}
testOptions {
unitTests {
isIncludeAndroidResources = true
unitTests.isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
)
}
}
aboutLibraries {
offlineMode = true
collect {
// Define the path configuration files are located in. E.g. additional libraries, licenses to add to the target .json
// Warning: Please do not use the parent folder of a module as path, as this can result in issues. More details: https://github.com/mikepenz/AboutLibraries/issues/936
// The path provided is relative to the modules path (not project root)
configPath = file("../config")
}
export {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields.add("generated")
}
}
dependencies {
implementation(projects.data)
implementation(projects.domain)
implementation(projects.mapsUtils)
implementation(projects.uiWeatherView)
implementation(projects.weatherUnit)
implementation(libs.breezy.datasharing.lib)
implementation(libs.core.ktx)
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.core.splashscreen)
implementation(libs.cardview)
implementation(libs.swiperefreshlayout)
implementation(platform(libs.compose.bom))
implementation(libs.activity.compose)
implementation(libs.compose.material.ripple)
implementation(libs.compose.animation)
implementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.util)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.navigation.compose)
lintChecks(libs.compose.lint.checks)
implementation(libs.accompanist.permissions)
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform)
// preference.
implementation(libs.preference.ktx)
// db
implementation(libs.bundles.sqlite)
// work.
implementation(libs.work.runtime)
// lifecycle.
implementation(libs.bundles.lifecycle)
implementation(libs.recyclerview)
// hilt.
implementation(libs.dagger.hilt.core)
ksp(libs.dagger.hilt.compiler)
implementation(libs.hilt.work)
ksp(libs.hilt.compiler)
// HTTP
implementation(libs.bundles.retrofit)
implementation(libs.bundles.okhttp)
// implementation(libs.kotlinx.serialization.csv) // Can be reenabled if needed (see also HttpModule.kt)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.xml.core)
implementation(libs.kotlinx.serialization.xml)
// data store
// implementation(libs.datastore)
// jwt - Only used by MF at the moment
"basicImplementation"(libs.jjwt.api)
"basicRuntimeOnly"(libs.jjwt.impl)
"basicRuntimeOnly"(libs.jjwt.orgjson) {
exclude("org.json", "json") // provided by Android natively
}
// rx java.
implementation(libs.rxjava)
implementation(libs.rxandroid)
implementation(libs.kotlinx.coroutines.rx3)
// ui.
implementation(libs.vico.compose.m3)
implementation(libs.vico.views)
implementation(libs.adaptiveiconview)
implementation(libs.activity)
// utils.
implementation(libs.suncalc)
implementation(libs.aboutLibraries)
// Allows reflection of the relative time class to pass Locale as parameter
implementation(libs.restrictionBypass)
// debugImplementation because LeakCanary should only run in debug builds.
// debugImplementation(libs.leakcanary)
}
tasks {
// May be too heavy to run, so lets keep the generated file in Git
// val naturalEarthConfigTask = registerNaturalEarthConfigTask(project)
val localesConfigTask = registerLocalesConfigTask(project)
// Duplicating Hebrew string assets due to some locale code issues on different devices
val copyHebrewStrings by registering(Copy::class) {
from("./src/main/res/values-he")
into("./src/main/res/values-iw")
include("**/*")
}
// Duplicating Indonesian string assets due to some locale code issues on different devices
val copyIndonesianStrings by registering(Copy::class) {
from("./src/main/res/values-id")
into("./src/main/res/values-in")
include("**/*")
}
preBuild {
dependsOn(
// naturalEarthConfigTask,
copyHebrewStrings,
copyIndonesianStrings,
localesConfigTask
)
}
}
buildscript {
dependencies {
classpath(libs.kotlin.gradle)
}
}

73
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,73 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class org.breezyweather.common.activities.models.** { *; }
-keep class org.breezyweather.db.entities.** { *; }
-keep interface org.breezyweather.sources.**.* { *; }
-keep class org.breezyweather.sources.**.json.** { *; }
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep class androidx.lifecycle.** {*;}
-keep class android.arch.lifecycle.** {*;}
-keep class **.R$* {*;}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
!private <fields>;
!private <methods>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
-keepclassmembers class * {
void *(**On*Event);
void *(**On*Listener);
}
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int i(...);
public static int w(...);
public static int d(...);
public static int e(...);
}
# suncalc
-dontwarn edu.umd.cs.findbugs.annotations.Nullable
# RestrictionBypass
-keep class org.chickenhook.restrictionbypass.** { *; }
# Jwt
-keep class io.jsonwebtoken.impl.** { *; }

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- theme -->
<color name="colorSplashScreen">#400000</color>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- theme -->
<color name="colorSplashScreen">#800000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#800000</color>
</resources>

View file

@ -0,0 +1,576 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- location. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- request for some location SDKs and reading wallpaper in widget config activities. -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- background jobs -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- query internet state. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- widgets. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<!-- tiles. -->
<uses-permission android:name="android.permission.EXPAND_STATUS_BAR" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" />
<!-- notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.READ_APP_SPECIFIC_LOCALES"
tools:ignore="ProtectedPermissions" />
<!-- weather update in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Data sharing -->
<permission
android:description="@string/content_provider_permission_description"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/content_provider_permission_label"
android:name="${applicationId}.READ_PROVIDER"
android:protectionLevel="dangerous" />
<uses-feature
android:name="android.software.live_wallpaper"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<uses-feature
android:name="android.hardware.location.network"
android:required="false" />
<queries>
<!-- Breezy Weather Icon Packs -->
<intent>
<action android:name="org.breezyweather.ICON_PROVIDER" />
</intent>
<!-- Geometric Weather Icon Packs -->
<intent>
<action android:name="wangdaye.com.geometricweather.ICON_PROVIDER" />
</intent>
<!-- Chronus Icon Packs -->
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
<!-- GadgetBridge WeatherSpec -->
<intent>
<action android:name="nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" />
</intent>
</queries>
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/breezy_weather"
android:name=".BreezyWeather"
android:supportsRtl="true"
android:largeHeap="true"
android:localeConfig="@xml/locales_config"
android:theme="@style/BreezyWeatherTheme"
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config"
android:allowCrossUidActivitySwitchFromBelow="false"
tools:ignore="AllowBackup,GoogleAppIndexingWarning,ManifestResource,RtlEnabled,UnusedAttribute"
tools:targetApi="n">
<meta-data
android:name="org.breezyweather.PROVIDER_CONFIG"
android:resource="@xml/icon_provider_config" />
<meta-data
android:name="org.breezyweather.DRAWABLE_FILTER"
android:resource="@xml/icon_provider_drawable_filter" />
<meta-data
android:name="org.breezyweather.ANIMATOR_FILTER"
android:resource="@xml/icon_provider_animator_filter" />
<meta-data
android:name="org.breezyweather.SHORTCUT_FILTER"
android:resource="@xml/icon_provider_shortcut_filter" />
<meta-data
android:name="org.breezyweather.SUN_MOON_FILTER"
android:resource="@xml/icon_provider_sun_moon_filter" />
<activity
android:name=".ui.main.MainActivity"
android:label="@string/app_name"
android:theme="@style/BreezyWeatherTheme.Main"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.APP_WEATHER" />
</intent-filter>
<intent-filter>
<!--<action android:name="org.breezyweather.ICON_PROVIDER" />-->
<action android:name="${applicationId}.Main" />
<action android:name="${applicationId}.ACTION_SHOW_ALERTS" />
<action android:name="${applicationId}.ACTION_SHOW_DAILY_FORECAST" />
<action android:name="${applicationId}.ACTION_MANAGEMENT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter android:label="@string/action_add_as_location">
<data android:scheme="geo" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>
<activity android:name=".ui.search.SearchActivity"
android:label="@string/action_search"
android:theme="@style/BreezyWeatherTheme.Search"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.details.DetailsActivity"
android:label="@string/daily_forecast"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.alert.AlertActivity"
android:label="@string/alerts"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.SettingsActivity"
android:exported="true"
android:label="@string/action_settings"
android:theme="@style/BreezyWeatherTheme">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.settings.activities.CardDisplayManageActivity"
android:label="@string/settings_main_cards_title"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.DailyTrendDisplayManageActivity"
android:label="@string/settings_main_daily_trends_title"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.HourlyTrendDisplayManageActivity"
android:label="@string/settings_main_hourly_trends_title"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.PreviewIconActivity"
android:label="@string/action_preview"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.WorkerInfoActivity"
android:label="@string/settings_background_updates_worker_info_title"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.about.AboutActivity"
android:label="@string/action_about"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.DependenciesActivity"
android:label="@string/action_dependencies"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".ui.settings.activities.PrivacyPolicyActivity"
android:label="@string/about_privacy_policy"
android:theme="@style/BreezyWeatherTheme" />
<activity
android:name=".wallpaper.LiveWallpaperConfigActivity"
android:label="@string/settings_modules_live_wallpaper_title"
android:theme="@style/BreezyWeatherTheme"
android:exported="true" />
<!-- widget -->
<activity
android:name=".remoteviews.config.DayWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.WeekWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.DayWeekWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.ClockDayHorizontalWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.ClockDayDetailsWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.ClockDayVerticalWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.ClockDayWeekWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.TextWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.DailyTrendWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.HourlyTrendWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity
android:name=".remoteviews.config.MultiCityWidgetConfigActivity"
android:theme="@style/BreezyWeatherTheme"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- service -->
<service
android:name=".background.interfaces.TileService"
android:foregroundServiceType="specialUse"
android:label="@string/breezy_weather"
android:icon="@drawable/weather_clear_day_mini_light"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="tile" />
</service>
<service
android:name=".wallpaper.MaterialLiveWallpaperService"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_WALLPAPER"
android:exported="true">
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService" />
</intent-filter>
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/live_wallpaper" />
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="wallpaper" />
</service>
<receiver
android:name=".background.receiver.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.NotificationReceiver"
android:exported="false" />
<!-- widget -->
<receiver
android:name=".background.receiver.widget.WidgetMaterialYouForecastProvider"
android:label="@string/widget_material_you_forecast"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_material_you_forecast" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetMaterialYouCurrentProvider"
android:label="@string/widget_material_you_current"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_material_you_current" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_ENABLED" />
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_OPTIONS_CHANGED" />
<action android:name="android.appwidget.action.APPWIDGET_UPDATE_OPTIONS" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetDayProvider"
android:label="@string/widget_day"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_day" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetWeekProvider"
android:label="@string/widget_week"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_week" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetDayWeekProvider"
android:label="@string/widget_day_week"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_day_week" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetClockDayHorizontalProvider"
android:label="@string/widget_clock_day_horizontal"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_day_horizontal" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetClockDayDetailsProvider"
android:label="@string/widget_clock_day_details"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_day_details" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetClockDayVerticalProvider"
android:label="@string/widget_clock_day_vertical"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_day_vertical" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetClockDayWeekProvider"
android:label="@string/widget_clock_day_week"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_clock_day_week" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetTextProvider"
android:label="@string/widget_text"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_text" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetTrendDailyProvider"
android:label="@string/widget_trend_daily"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_trend_daily" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetTrendHourlyProvider"
android:label="@string/widget_trend_hourly"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_trend_hourly" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<receiver
android:name=".background.receiver.widget.WidgetMultiCityProvider"
android:label="@string/widget_multi_city"
android:exported="true">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_multi_city" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.ACTION_APPWIDGET_DISABLED" />
</intent-filter>
</receiver>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync|shortService"
tools:node="merge" />
<!--<provider
android:name=".background.provider.WeatherContentProvider"
android:authorities="${applicationId}.provider.weather"
android:exported="true"
android:readPermission="${applicationId}.READ_PROVIDER" />-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,155 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather
import android.app.Application
import android.app.UiModeManager
import android.content.pm.ApplicationInfo
import android.os.Build
import android.os.Process
import androidx.appcompat.app.AppCompatDelegate
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import dagger.hilt.android.HiltAndroidApp
import org.breezyweather.common.activities.BreezyActivity
import org.breezyweather.common.extensions.uiModeManager
import org.breezyweather.common.extensions.workManager
import org.breezyweather.common.utils.helpers.LogHelper
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.remoteviews.Notifications
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import javax.inject.Inject
@HiltAndroidApp
class BreezyWeather : Application(), Configuration.Provider {
companion object {
lateinit var instance: BreezyWeather
private set
fun getProcessName() = try {
val file = File("/proc/" + Process.myPid() + "/" + "cmdline")
val mBufferedReader = BufferedReader(FileReader(file))
val processName = mBufferedReader.readLine().trim {
it <= ' '
}
mBufferedReader.close()
processName
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private val activitySet: MutableSet<BreezyActivity> by lazy {
HashSet()
}
var topActivity: BreezyActivity? = null
private set
val debugMode: Boolean by lazy {
applicationInfo != null && applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
}
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
instance = this
setupNotificationChannels()
if (getProcessName().equals(packageName)) {
// Sets and persists the night mode setting for this app. This allows the system to know
// if the app wants to be displayed in dark mode before it launches so that the splash
// screen can be displayed accordingly.
setDayNightMode()
}
/**
* We dont use the return value, but querying the work manager might help bringing back
* scheduled workers after the app has been killed/shutdown on some devices
*/
this.workManager.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
}
fun addActivity(a: BreezyActivity) {
activitySet.add(a)
}
fun removeActivity(a: BreezyActivity) {
activitySet.remove(a)
}
fun setTopActivity(a: BreezyActivity) {
topActivity = a
}
fun checkToCleanTopActivity(a: BreezyActivity) {
if (topActivity === a) {
topActivity = null
}
}
fun recreateAllActivities() {
val topA = topActivity
for (a in activitySet) {
if (a != topA) a.recreate()
}
// ensure that top activity stays on top by recreating it last
topA?.recreate()
}
private fun setDayNightMode() {
updateDayNightMode(SettingsManager.getInstance(this).darkMode.value)
}
fun updateDayNightMode(dayNightMode: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
uiModeManager?.setApplicationNightMode(
when (dayNightMode) {
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_AUTO
}
)
} else {
AppCompatDelegate.setDefaultNightMode(dayNightMode)
}
}
private fun setupNotificationChannels() {
try {
Notifications.createChannels(this)
} catch (e: Exception) {
LogHelper.log(msg = "Failed to setup notification channels")
}
}
override val workManagerConfiguration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}

View file

@ -0,0 +1,307 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather
import android.Manifest
import android.content.Context
import android.os.Build
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import breezyweather.domain.source.SourceFeature
import kotlinx.coroutines.runBlocking
import org.breezyweather.background.forecast.TodayForecastNotificationJob
import org.breezyweather.background.forecast.TomorrowForecastNotificationJob
import org.breezyweather.background.weather.WeatherUpdateJob
import org.breezyweather.common.options.appearance.DailyTrendDisplay
import org.breezyweather.common.options.appearance.HourlyTrendDisplay
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.sources.SourceManager
import org.breezyweather.ui.main.utils.StatementManager
import java.io.File
object Migrations {
/**
* Performs a migration when the application is updated.
*
* @return true if a migration is performed, false otherwise.
*/
fun upgrade(
context: Context,
sourceManager: SourceManager,
locationRepository: LocationRepository,
weatherRepository: WeatherRepository,
): Boolean {
val lastVersionCode = SettingsManager.getInstance(context).lastVersionCode
val oldVersion = lastVersionCode
if (oldVersion < BuildConfig.VERSION_CODE) {
if (oldVersion > 0) { // Not fresh install
if (oldVersion < 50000) {
// V5.0.0 adds many new charts
// Adding it to people who customized their hourly trends tabs so they don't miss
// this new feature. This can still be removed by user from settings
// as this code is only executed once, after migrating from a version < 5.0.0
try {
val curHourlyTrendDisplayList = HourlyTrendDisplay.toValue(
SettingsManager.getInstance(context).hourlyTrendDisplayList
)
if (curHourlyTrendDisplayList != SettingsManager.DEFAULT_HOURLY_TREND_DISPLAY) {
SettingsManager.getInstance(context).hourlyTrendDisplayList =
HourlyTrendDisplay.toHourlyTrendDisplayList(
"$curHourlyTrendDisplayList&feels_like&humidity&pressure&cloud_cover&visibility"
)
}
val curDailyTrendDisplayList = DailyTrendDisplay.toValue(
SettingsManager.getInstance(context).dailyTrendDisplayList
)
if (curDailyTrendDisplayList != SettingsManager.DEFAULT_DAILY_TREND_DISPLAY) {
SettingsManager.getInstance(context).dailyTrendDisplayList =
DailyTrendDisplay.toDailyTrendDisplayList("$curDailyTrendDisplayList&feels_like")
}
} catch (ignored: Throwable) {
// ignored
}
// Delete old ObjectBox database
context.applicationInfo?.dataDir?.let {
val file = File("$it/files/objectbox/")
if (file.exists() && file.isDirectory) {
file.deleteRecursively()
}
}
}
if (oldVersion < 50102) {
// V5.1.2 adds daily sunshine chart
try {
val curDailyTrendDisplayList =
DailyTrendDisplay.toValue(SettingsManager.getInstance(context).dailyTrendDisplayList)
if (curDailyTrendDisplayList != SettingsManager.DEFAULT_DAILY_TREND_DISPLAY) {
SettingsManager.getInstance(context).dailyTrendDisplayList =
DailyTrendDisplay.toDailyTrendDisplayList("$curDailyTrendDisplayList&sunshine")
}
} catch (ignored: Throwable) {
// ignored
}
}
if (oldVersion < 50400) {
// V5.4.0 changes the way empty source value work on locations
runBlocking {
locationRepository.getAllLocations(withParameters = false)
.forEach {
val source = sourceManager.getWeatherSource(it.forecastSource)
if (source != null) {
locationRepository.update(
it.copy(
currentSource = if (it.currentSource.isNullOrEmpty() &&
SourceFeature.CURRENT in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.CURRENT)
) {
source.id
} else {
it.currentSource
},
airQualitySource = if (it.airQualitySource.isNullOrEmpty() &&
SourceFeature.AIR_QUALITY in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.AIR_QUALITY)
) {
source.id
} else {
it.airQualitySource
},
pollenSource = if (it.pollenSource.isNullOrEmpty() &&
SourceFeature.POLLEN in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.POLLEN)
) {
source.id
} else {
it.pollenSource
},
minutelySource = if (it.minutelySource.isNullOrEmpty() &&
SourceFeature.MINUTELY in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.MINUTELY)
) {
source.id
} else {
it.minutelySource
},
alertSource = if (it.alertSource.isNullOrEmpty() &&
SourceFeature.ALERT in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.ALERT)
) {
source.id
} else {
it.alertSource
},
normalsSource = if (it.normalsSource.isNullOrEmpty() &&
SourceFeature.NORMALS in source.supportedFeatures &&
source.isFeatureSupportedForLocation(it, SourceFeature.NORMALS)
) {
source.id
} else {
it.normalsSource
}
)
)
}
}
}
}
if (oldVersion < 50402) {
try {
// We cannot determine if the permission was permanently denied in the past. That is why we
// need to update the state for all users updating from an older version.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
StatementManager(context).setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS)
}
} catch (ignored: Throwable) {
// ignored
}
}
if (oldVersion < 50403) {
// V5.4.3 no longer uses forecastSource as reverseGeocodingSource. Migrates current location
runBlocking {
locationRepository.getAllLocations(withParameters = false)
.forEach {
if (it.isCurrentPosition) {
val source = sourceManager.getReverseGeocodingSource(it.forecastSource)
if (source != null &&
source.isFeatureSupportedForLocation(it, SourceFeature.REVERSE_GEOCODING)
) {
locationRepository.update(
it.copy(
reverseGeocodingSource = source.id
)
)
}
}
}
}
}
if (oldVersion < 50407) {
runBlocking {
// V5.4.7 removes incorrect INSEE code for Paris, Marseille, Lyon with Atmo France
locationRepository.updateParameters(
source = "atmofrance",
parameter = "citycode",
values = mapOf(
"75101" to "75056", // Paris
"75102" to "75056", // Paris
"75103" to "75056", // Paris
"75104" to "75056", // Paris
"75105" to "75056", // Paris
"75106" to "75056", // Paris
"75107" to "75056", // Paris
"75108" to "75056", // Paris
"75109" to "75056", // Paris
"75110" to "75056", // Paris
"75111" to "75056", // Paris
"75112" to "75056", // Paris
"75113" to "75056", // Paris
"75114" to "75056", // Paris
"75115" to "75056", // Paris
"75116" to "75056", // Paris
"75117" to "75056", // Paris
"75118" to "75056", // Paris
"75119" to "75056", // Paris
"75120" to "75056", // Paris
"13201" to "13055", // Marseille
"13202" to "13055", // Marseille
"13203" to "13055", // Marseille
"13204" to "13055", // Marseille
"13205" to "13055", // Marseille
"13206" to "13055", // Marseille
"13207" to "13055", // Marseille
"13208" to "13055", // Marseille
"13209" to "13055", // Marseille
"13210" to "13055", // Marseille
"13211" to "13055", // Marseille
"13212" to "13055", // Marseille
"13213" to "13055", // Marseille
"13214" to "13055", // Marseille
"13215" to "13055", // Marseille
"13216" to "13055", // Marseille
"69381" to "69123", // Lyon
"69382" to "69123", // Lyon
"69383" to "69123", // Lyon
"69384" to "69123", // Lyon
"69385" to "69123", // Lyon
"69386" to "69123", // Lyon
"69387" to "69123", // Lyon
"69388" to "69123", // Lyon
"69389" to "69123" // Lyon
)
)
// V5.4.7 migrates some Open-Meteo weather models
locationRepository.updateParameters(
source = "openmeteo",
parameter = "weatherModels",
values = mapOf(
"ecmwf_ifs04" to "ecmwf_ifs025",
"ecmwf_aifs025" to "ecmwf_aifs025_single",
"arpae_cosmo_seamless" to "italia_meteo_arpae_icon_2i",
"arpae_cosmo_2i" to "italia_meteo_arpae_icon_2i",
"arpae_cosmo_5m" to "italia_meteo_arpae_icon_2i"
)
)
}
}
if (oldVersion < 60005) {
runBlocking {
// V6.0.5 makes so many database migrations that the data from previous versions is unusable
// so lets force a refresh
weatherRepository.deleteAllWeathers()
// V6.0.5 restricts Open-Meteo pollen to Europe, and Accu to US/Europe
locationRepository.getAllLocations(withParameters = false)
.forEach {
if (it.pollenSource in arrayOf("openmeteo", "accu")) {
val source = sourceManager.getWeatherSource(it.pollenSource!!)
if (source == null ||
!source.isFeatureSupportedForLocation(it, SourceFeature.POLLEN)
) {
locationRepository.update(
it.copy(
pollenSource = ""
)
)
}
}
}
}
}
}
SettingsManager.getInstance(context).lastVersionCode = BuildConfig.VERSION_CODE
// Always set up background tasks to ensure they're running
WeatherUpdateJob.setupTask(context) // This will also refresh data immediately
TodayForecastNotificationJob.setupTask(context, false)
TomorrowForecastNotificationJob.setupTask(context, false)
return oldVersion != 0
}
return false
}
}

View file

@ -0,0 +1,179 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.forecast
import android.app.Notification
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import androidx.core.app.NotificationCompat
import breezyweather.domain.location.model.Location
import breezyweather.domain.weather.model.Daily
import breezyweather.domain.weather.reference.WeatherCode
import org.breezyweather.R
import org.breezyweather.common.extensions.cancelNotification
import org.breezyweather.common.extensions.formatMeasure
import org.breezyweather.common.extensions.notificationBuilder
import org.breezyweather.common.extensions.notify
import org.breezyweather.common.extensions.toBitmap
import org.breezyweather.domain.location.model.isDaylight
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.remoteviews.Notifications
import org.breezyweather.remoteviews.presenters.AbstractRemoteViewsPresenter
import org.breezyweather.ui.theme.resource.ResourceHelper
import org.breezyweather.ui.theme.resource.ResourcesProviderFactory
import org.breezyweather.unit.formatting.UnitWidth
import org.breezyweather.unit.temperature.TemperatureUnit
class ForecastNotificationNotifier(
private val context: Context,
) {
private val progressNotificationBuilder = context
.notificationBuilder(Notifications.CHANNEL_FORECAST) {
setSmallIcon(R.drawable.ic_running_in_background)
setAutoCancel(false)
setOngoing(true)
setOnlyAlertOnce(true)
}
private val completeNotificationBuilder = context
.notificationBuilder(Notifications.CHANNEL_FORECAST) {
setAutoCancel(false)
}
fun showProgress(): Notification {
return progressNotificationBuilder
// prevent Android from muting notifications ('muting recently noisy')
// and only play a sound for the actual forecast notification
.setSilent(true)
.setContentTitle(context.getString(R.string.notification_running_in_background))
.build()
}
fun showComplete(location: Location, today: Boolean) {
context.cancelNotification(
if (today) {
Notifications.ID_UPDATING_TODAY_FORECAST
} else {
Notifications.ID_UPDATING_TOMORROW_FORECAST
}
)
val weather = location.weather ?: return
val daily = (if (today) weather.today else weather.tomorrow) ?: return
val provider = ResourcesProviderFactory.newInstance
val daytime: Boolean = if (today) location.isDaylight else true
val weatherCode: WeatherCode? = if (today) {
if (daytime) daily.day?.weatherCode else daily.night?.weatherCode
} else {
daily.day?.weatherCode
}
val temperatureUnit = SettingsManager.getInstance(context).getTemperatureUnit(context)
val notification: Notification = with(completeNotificationBuilder) {
priority = NotificationCompat.PRIORITY_MAX
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
setSubText(
if (today) {
context.getString(R.string.daily_today_short)
} else {
context.getString(R.string.daily_tomorrow_short)
}
)
setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE)
setAutoCancel(true)
setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
setSmallIcon(ResourceHelper.getDefaultMinimalXmlIconId(weatherCode, daytime))
weatherCode?.let {
setLargeIcon(ResourceHelper.getWeatherIcon(provider, it, daytime).toBitmap())
}
setContentTitle(getDayString(daily, temperatureUnit))
setContentText(getNightString(daily, temperatureUnit))
setStyle(
NotificationCompat.BigTextStyle()
.bigText(
getDayString(daily, temperatureUnit) +
"\n\n" +
getNightString(daily, temperatureUnit)
)
// do not show any title when expanding the notification
.setBigContentTitle("")
)
setContentIntent(
AbstractRemoteViewsPresenter.getWeatherPendingIntent(
context,
null,
if (today) {
Notifications.ID_TODAY_FORECAST
} else {
Notifications.ID_TOMORROW_FORECAST
}
)
)
}.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
weather.current?.weatherCode != null
) {
try {
notification.javaClass
.getMethod("setSmallIcon", Icon::class.java)
.invoke(
notification,
ResourceHelper.getMinimalIcon(
provider,
weather.current!!.weatherCode!!,
daytime
)
)
} catch (ignore: Exception) {
// do nothing.
}
}
context.notify(
if (today) Notifications.ID_TODAY_FORECAST else Notifications.ID_TOMORROW_FORECAST,
notification
)
}
private fun getDayString(daily: Daily, temperatureUnit: TemperatureUnit) =
context.getString(R.string.daytime) +
context.getString(R.string.colon_separator) +
daily.day?.temperature?.temperature?.formatMeasure(
context,
temperatureUnit,
valueWidth = UnitWidth.NARROW
) +
context.getString(R.string.dot_separator) +
daily.day?.weatherText
private fun getNightString(daily: Daily, temperatureUnit: TemperatureUnit) =
context.getString(R.string.nighttime) +
context.getString(R.string.colon_separator) +
daily.night?.temperature?.temperature?.formatMeasure(
context,
temperatureUnit,
valueWidth = UnitWidth.NARROW
) +
context.getString(R.string.dot_separator) +
daily.night?.weatherText
}

View file

@ -0,0 +1,148 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.forecast
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.breezyweather.common.extensions.cancelNotification
import org.breezyweather.common.extensions.hasNotificationPermission
import org.breezyweather.common.extensions.isRunning
import org.breezyweather.common.extensions.setForegroundSafely
import org.breezyweather.common.extensions.workManager
import org.breezyweather.common.utils.helpers.LogHelper
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.remoteviews.Notifications
import java.util.Calendar
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
@HiltWorker
class TodayForecastNotificationJob @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val locationRepository: LocationRepository,
private val weatherRepository: WeatherRepository,
) : CoroutineWorker(context, workerParams) {
private val notifier = ForecastNotificationNotifier(context)
override suspend fun doWork(): Result {
setForegroundSafely()
return try {
if (SettingsManager.getInstance(context).isTodayForecastEnabled) {
val location = locationRepository.getFirstLocation(withParameters = false)
if (location != null) {
notifier.showComplete(
location.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
),
today = true
)
}
}
Result.success()
} catch (e: Exception) {
e.message?.let { LogHelper.log(msg = it) }
Result.failure()
} finally {
context.cancelNotification(Notifications.ID_UPDATING_TODAY_FORECAST)
// Add a new job in 24 hours
setupTask(context, nextDay = true)
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
Notifications.ID_UPDATING_TODAY_FORECAST,
notifier.showProgress(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
} else {
0
}
)
}
companion object {
private const val TAG = "ForecastNotificationToday"
fun isRunning(context: Context): Boolean {
return context.workManager.isRunning(TAG)
}
fun setupTask(context: Context, nextDay: Boolean) {
val settings = SettingsManager.getInstance(context)
if (settings.isTodayForecastEnabled) {
if (context.hasNotificationPermission) {
val request = OneTimeWorkRequestBuilder<TodayForecastNotificationJob>()
.setInitialDelay(
getForecastAlarmDelayInMinutes(settings.todayForecastTime, nextDay),
TimeUnit.MINUTES
)
.addTag(TAG)
.build()
context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
return
} else {
settings.isTodayForecastEnabled = false
}
}
context.workManager.cancelUniqueWork(TAG)
}
fun stop(context: Context) {
context.workManager.cancelUniqueWork(TAG)
}
private fun getForecastAlarmDelayInMinutes(time: String, nextDay: Boolean): Long {
val realTimes = intArrayOf(
Calendar.getInstance()[Calendar.HOUR_OF_DAY],
Calendar.getInstance()[Calendar.MINUTE]
)
val setTimes = intArrayOf(
time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0].toInt(),
time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].toInt()
)
var delay = (setTimes[0] - realTimes[0]).hours.inWholeMinutes + (setTimes[1] - realTimes[1])
if (delay <= 0 || nextDay) {
delay += 1.days.inWholeMinutes
}
return delay
}
}
}

View file

@ -0,0 +1,148 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.forecast
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.breezyweather.common.extensions.cancelNotification
import org.breezyweather.common.extensions.hasNotificationPermission
import org.breezyweather.common.extensions.isRunning
import org.breezyweather.common.extensions.setForegroundSafely
import org.breezyweather.common.extensions.workManager
import org.breezyweather.common.utils.helpers.LogHelper
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.remoteviews.Notifications
import java.util.Calendar
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
@HiltWorker
class TomorrowForecastNotificationJob @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val locationRepository: LocationRepository,
private val weatherRepository: WeatherRepository,
) : CoroutineWorker(context, workerParams) {
private val notifier = ForecastNotificationNotifier(context)
override suspend fun doWork(): Result {
setForegroundSafely()
return try {
if (SettingsManager.getInstance(context).isTomorrowForecastEnabled) {
val location = locationRepository.getFirstLocation(withParameters = false)
if (location != null) {
notifier.showComplete(
location.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
),
today = false
)
}
}
Result.success()
} catch (e: Exception) {
e.message?.let { LogHelper.log(msg = it) }
Result.failure()
} finally {
context.cancelNotification(Notifications.ID_UPDATING_TOMORROW_FORECAST)
// Add a new job in 24 hours
setupTask(context, nextDay = true)
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
Notifications.ID_UPDATING_TOMORROW_FORECAST,
notifier.showProgress(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
} else {
0
}
)
}
companion object {
private const val TAG = "ForecastNotificationTomorrow"
fun isRunning(context: Context): Boolean {
return context.workManager.isRunning(TAG)
}
fun setupTask(context: Context, nextDay: Boolean) {
val settings = SettingsManager.getInstance(context)
if (settings.isTomorrowForecastEnabled) {
if (context.hasNotificationPermission) {
val request = OneTimeWorkRequestBuilder<TomorrowForecastNotificationJob>()
.setInitialDelay(
getForecastAlarmDelayInMinutes(settings.tomorrowForecastTime, nextDay),
TimeUnit.MINUTES
)
.addTag(TAG)
.build()
context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
return
} else {
settings.isTomorrowForecastEnabled = false
}
}
context.workManager.cancelUniqueWork(TAG)
}
fun stop(context: Context) {
context.workManager.cancelUniqueWork(TAG)
}
private fun getForecastAlarmDelayInMinutes(time: String, nextDay: Boolean): Long {
val realTimes = intArrayOf(
Calendar.getInstance()[Calendar.HOUR_OF_DAY],
Calendar.getInstance()[Calendar.MINUTE]
)
val setTimes = intArrayOf(
time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0].toInt(),
time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].toInt()
)
var delay = (setTimes[0] - realTimes[0]).hours.inWholeMinutes + (setTimes[1] - realTimes[1])
if (delay <= 0 || nextDay) {
delay += 1.days.inWholeMinutes
}
return delay
}
}
}

View file

@ -0,0 +1,123 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.interfaces
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.breezyweather.common.extensions.formatMeasure
import org.breezyweather.domain.location.model.isDaylight
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.ui.main.MainActivity
import org.breezyweather.ui.theme.resource.ResourceHelper
import org.breezyweather.ui.theme.resource.ResourcesProviderFactory
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/**
* Tile service.
* TODO: Memory leak
*/
@AndroidEntryPoint
@RequiresApi(Build.VERSION_CODES.N)
class TileService : TileService(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
override fun onTileAdded() {
refreshTile(this, qsTile)
}
override fun onTileRemoved() {
// do nothing.
}
override fun onStartListening() {
refreshTile(this, qsTile)
}
override fun onStopListening() {
refreshTile(this, qsTile)
}
@SuppressLint("StartActivityAndCollapseDeprecated")
override fun onClick() {
val intent = Intent(MainActivity.ACTION_MAIN)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
this.startActivityAndCollapse(
PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
)
} else {
@Suppress("DEPRECATION")
this.startActivityAndCollapse(intent)
}
}
private fun refreshTile(context: Context, tile: Tile?) {
if (tile == null) return
launch {
val location = locationRepository.getFirstLocation(withParameters = false) ?: return@launch
val locationRefreshed = location.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true, // isDaylight
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
locationRefreshed.weather?.current?.let { current ->
tile.apply {
current.weatherCode?.let {
icon = ResourceHelper.getMinimalIcon(
ResourcesProviderFactory.newInstance,
it,
locationRefreshed.isDaylight
)
}
tile.label = current.temperature?.temperature?.formatMeasure(
context,
SettingsManager.getInstance(context).getTemperatureUnit(context)
)
state = Tile.STATE_INACTIVE
}
tile.updateTile()
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.common.extensions.workManager
import org.breezyweather.remoteviews.presenters.notification.WidgetNotificationIMP
import org.breezyweather.sources.RefreshHelper
import javax.inject.Inject
/**
* Receiver to force app to autostart on boot
* Does nothing, its just that some OEM do not respect Android policy to keep scheduled workers
* regardless of if the app is started or not
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var refreshHelper: RefreshHelper
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action.isNullOrEmpty()) return
when (action) {
Intent.ACTION_BOOT_COMPLETED -> {
/**
* We dont use the return value, but querying the work manager might help bringing back
* scheduled workers after the app has been killed/shutdown on some devices
*/
context.workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED))
// Bring back notification-widget if necessary
if (WidgetNotificationIMP.isEnabled(context)) {
GlobalScope.launch(Dispatchers.IO) {
refreshHelper.updateNotificationIfNecessary(context)
}
}
}
}
}
}

View file

@ -0,0 +1,150 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import org.breezyweather.background.weather.WeatherUpdateJob
import org.breezyweather.common.extensions.cancelNotification
import org.breezyweather.common.extensions.notificationManager
import org.breezyweather.BuildConfig.APPLICATION_ID as ID
/**
* Taken partially from Mihon
* License Apache, Version 2.0
* https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt
*/
/**
* Global [BroadcastReceiver] that runs on UI thread
* Pending Broadcasts should be made from here.
* NOTE: Use local broadcasts if possible.
*/
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// Cancel weather update and dismiss notification
ACTION_CANCEL_WEATHER_UPDATE -> cancelWeatherUpdate(context)
}
}
/**
* Dismiss the notification
*
* @param notificationId the id of the notification
*/
private fun dismissNotification(context: Context, notificationId: Int) {
context.cancelNotification(notificationId)
}
/**
* Method called when user wants to stop a weather update
*
* @param context context of application
*/
private fun cancelWeatherUpdate(context: Context) {
WeatherUpdateJob.stop(context)
}
companion object {
private const val NAME = "NotificationReceiver"
private const val ACTION_CANCEL_WEATHER_UPDATE = "$ID.$NAME.CANCEL_WEATHER_UPDATE"
/**
* Returns [PendingIntent] that starts a service which stops the weather update
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun cancelWeatherUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_WEATHER_UPDATE
}
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/**
* Returns [PendingIntent] that starts a service which dismissed the notification
*
* @param context context of application
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun dismissNotification(context: Context, notificationId: Int, groupId: Int? = null) {
/*
Group notifications always have at least 2 notifications:
- Group summary notification
- Single manga notification
If the single notification is dismissed by the system, ie by a user swipe or tapping on the notification,
it will auto dismiss the group notification if there's no other single updates.
When programmatically dismissing this notification, the group notification is not automatically dismissed.
*/
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val groupKey = context.notificationManager.activeNotifications.find {
it.id == notificationId
}?.groupKey
if (groupId != null && groupId != 0 && !groupKey.isNullOrEmpty()) {
val notifications = context.notificationManager.activeNotifications.filter {
it.groupKey == groupKey
}
if (notifications.size == 2) {
context.cancelNotification(groupId)
return
}
}
}
context.cancelNotification(notificationId)
}
/**
* Returns [PendingIntent] that opens the error log file in an external viewer
*
* @param context context of application
* @param uri uri of error log file
* @return [PendingIntent]
*/
internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "text/plain")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.ClockDayDetailsWidgetIMP
import javax.inject.Inject
/**
* Widget clock day details provider.
*/
@AndroidEntryPoint
class WidgetClockDayDetailsProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (ClockDayDetailsWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
ClockDayDetailsWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.ClockDayHorizontalWidgetIMP
import javax.inject.Inject
/**
* Widget clock day horizontal provider.
*/
@AndroidEntryPoint
class WidgetClockDayHorizontalProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (ClockDayHorizontalWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
ClockDayHorizontalWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true, // isDaylight
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
}

View file

@ -0,0 +1,79 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.ClockDayVerticalWidgetIMP
import org.breezyweather.sources.SourceManager
import javax.inject.Inject
/**
* Widget clock day vertical provider.
*/
@AndroidEntryPoint
class WidgetClockDayVerticalProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@Inject
lateinit var sourceManager: SourceManager
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (ClockDayVerticalWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
ClockDayVerticalWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = true, // Custom subtitle
withNormals = false
)
),
location?.let { locationNow ->
sourceManager.getPollenIndexSource(
(locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource }
)
}
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.ClockDayWeekWidgetIMP
import javax.inject.Inject
/**
* Widget clock day week provider.
*/
@AndroidEntryPoint
class WidgetClockDayWeekProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (ClockDayWeekWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
ClockDayWeekWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
}

View file

@ -0,0 +1,79 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.DayWidgetIMP
import org.breezyweather.sources.SourceManager
import javax.inject.Inject
/**
* Widget day provider.
*/
@AndroidEntryPoint
class WidgetDayProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@Inject
lateinit var sourceManager: SourceManager
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (DayWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
DayWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = true, // Custom subtitle
withNormals = false
)
),
location?.let { locationNow ->
sourceManager.getPollenIndexSource(
(locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource }
)
}
)
}
}
}
}

View file

@ -0,0 +1,79 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.DayWeekWidgetIMP
import org.breezyweather.sources.SourceManager
import javax.inject.Inject
/**
* Widget day week provider.
*/
@AndroidEntryPoint
class WidgetDayWeekProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@Inject
lateinit var sourceManager: SourceManager
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (DayWeekWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
DayWeekWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = true, // Custom subtitle
withNormals = false
)
),
location?.let { locationNow ->
sourceManager.getPollenIndexSource(
(locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource }
)
}
)
}
}
}
}

View file

@ -0,0 +1,77 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.os.Bundle
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.MaterialYouCurrentWidgetIMP
import javax.inject.Inject
@AndroidEntryPoint
class WidgetMaterialYouCurrentProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (MaterialYouCurrentWidgetIMP.isEnabled(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
MaterialYouCurrentWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true, // isDaylight
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle,
) {
onUpdate(context, appWidgetManager, intArrayOf(appWidgetId))
}
}

View file

@ -0,0 +1,67 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.MaterialYouForecastWidgetIMP
import javax.inject.Inject
@AndroidEntryPoint
class WidgetMaterialYouForecastProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (MaterialYouForecastWidgetIMP.isEnabled(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
MaterialYouForecastWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = true,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.MultiCityWidgetIMP
import javax.inject.Inject
/**
* Widget multi city provider.
*/
@AndroidEntryPoint
class WidgetMultiCityProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (MultiCityWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val locationList = locationRepository.getXLocations(3, withParameters = false).toMutableList()
for (i in locationList.indices) {
locationList[i] = locationList[i].copy(
weather = weatherRepository.getWeatherByLocationId(
locationList[i].formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
}
MultiCityWidgetIMP.updateWidgetView(context, locationList)
}
}
}
}

View file

@ -0,0 +1,79 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.TextWidgetIMP
import org.breezyweather.sources.SourceManager
import javax.inject.Inject
/**
* Widget text provider.
*/
@AndroidEntryPoint
class WidgetTextProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@Inject
lateinit var sourceManager: SourceManager
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (TextWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
TextWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true, // isDaylight
withHourly = false,
withMinutely = false,
withAlerts = true, // Custom subtitle
withNormals = false
)
),
location?.let { locationNow ->
sourceManager.getPollenIndexSource(
(locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource }
)
}
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.DailyTrendWidgetIMP
import javax.inject.Inject
/**
* Widget trend daily provider.
*/
@AndroidEntryPoint
class WidgetTrendDailyProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (DailyTrendWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
DailyTrendWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = true // Threshold lines
)
)
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.HourlyTrendWidgetIMP
import javax.inject.Inject
/**
* Widget trend hourly provider.
*/
@AndroidEntryPoint
class WidgetTrendHourlyProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (HourlyTrendWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
HourlyTrendWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true, // isDaylight
withHourly = true,
withMinutely = false,
withAlerts = false,
withNormals = true // Threshold lines
)
)
)
}
}
}
}

View file

@ -0,0 +1,70 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.receiver.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.breezyweather.remoteviews.presenters.WeekWidgetIMP
import javax.inject.Inject
/**
* Widget week provider.
*/
@AndroidEntryPoint
class WidgetWeekProvider : AppWidgetProvider() {
@Inject
lateinit var locationRepository: LocationRepository
@Inject
lateinit var weatherRepository: WeatherRepository
@OptIn(DelicateCoroutinesApi::class)
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
if (WeekWidgetIMP.isInUse(context)) {
GlobalScope.launch(Dispatchers.IO) {
val location = locationRepository.getFirstLocation(withParameters = false)
WeekWidgetIMP.updateWidgetView(
context,
location?.copy(
weather = weatherRepository.getWeatherByLocationId(
location.formattedId,
withDaily = true,
withHourly = false,
withMinutely = false,
withAlerts = false,
withNormals = false
)
)
)
}
}
}
}

View file

@ -0,0 +1,69 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater
import android.content.Context
import android.os.Build
import org.breezyweather.BuildConfig
import org.breezyweather.background.updater.interactor.GetApplicationRelease
import org.breezyweather.common.extensions.withIOContext
import javax.inject.Inject
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt
*/
class AppUpdateChecker @Inject constructor(
private val getApplicationRelease: GetApplicationRelease,
) {
suspend fun checkForUpdate(
context: Context,
forceCheck: Boolean = false,
): GetApplicationRelease.Result {
// Disable app update checks for older Android versions that we're going to drop support for
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
AppUpdateNotifier(context).promptOldAndroidVersion()
return GetApplicationRelease.Result.OsTooOld
}
return withIOContext {
val result = getApplicationRelease.await(
GetApplicationRelease.Arguments(
BuildConfig.VERSION_NAME,
GITHUB_ORG,
GITHUB_REPO,
forceCheck
)
)
when (result) {
is GetApplicationRelease.Result.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release)
else -> {}
}
result
}
}
}
val GITHUB_ORG = "breezy-weather"
val GITHUB_REPO = "breezy-weather"
val RELEASE_URL = "https://github.com/${GITHUB_REPO}/releases/tag/v${BuildConfig.VERSION_NAME}"

View file

@ -0,0 +1,100 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import org.breezyweather.R
import org.breezyweather.background.receiver.NotificationReceiver
import org.breezyweather.background.updater.model.Release
import org.breezyweather.common.extensions.notificationBuilder
import org.breezyweather.common.extensions.notify
import org.breezyweather.remoteviews.Notifications
internal class AppUpdateNotifier(
private val context: Context,
) {
private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_APP_UPDATE)
/**
* Call to show notification.
*
* @param id id of the notification channel.
*/
private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_APP_UPDATER) {
context.notify(id, build())
}
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER)
}
fun promptOldAndroidVersion() {
with(notificationBuilder) {
setContentTitle(context.getString(R.string.about_update_check_eol))
setSmallIcon(android.R.drawable.stat_sys_download_done)
clearActions()
}
notificationBuilder.show()
}
@SuppressLint("LaunchActivityFromNotification")
fun promptUpdate(release: Release) {
/*val updateIntent = NotificationReceiver.downloadAppUpdatePendingBroadcast(
context,
release.getDownloadLink(),
release.version,
)*/
val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).run {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
PendingIntent.getActivity(
context,
release.hashCode(),
this,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
with(notificationBuilder) {
setContentTitle(context.getString(R.string.notification_app_update_available))
setContentText(release.version)
setSmallIcon(android.R.drawable.stat_sys_download_done)
// setContentIntent(updateIntent)
setContentIntent(releaseIntent)
clearActions()
addAction(
android.R.drawable.stat_sys_download_done,
context.getString(R.string.action_download),
// updateIntent,
releaseIntent
)
/*addAction(
R.drawable.ic_info_24dp,
context.getString(R.string.whats_new),
releaseIntent,
)*/
}
notificationBuilder.show()
}
}

View file

@ -0,0 +1,31 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater.data
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Open-Meteo API
*/
interface GithubApi {
@GET("repos/{org}/{repository}/releases/latest")
suspend fun getLatest(
@Path("org") org: String,
@Path("repository") repository: String,
): GithubRelease
}

View file

@ -0,0 +1,56 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater.data
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/d29b7c4e5735dc137d578d3bcb3da1f0a02573e8/data/src/main/java/tachiyomi/data/release/GithubRelease.kt
*/
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.breezyweather.background.updater.model.Release
/**
* Contains information about the latest release from GitHub.
*/
@Serializable
data class GithubRelease(
@SerialName("tag_name") val version: String,
@SerialName("body") val info: String,
@SerialName("html_url") val releaseLink: String,
@SerialName("assets") val assets: List<GitHubAssets>,
)
/**
* Assets class containing download url.
*/
@Serializable
data class GitHubAssets(
@SerialName("browser_download_url") val downloadLink: String,
)
val releaseMapper: (GithubRelease) -> Release = {
Release(
it.version,
it.info,
it.releaseLink,
it.assets.map(GitHubAssets::downloadLink)
)
}

View file

@ -0,0 +1,42 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater.data
import org.breezyweather.background.updater.model.Release
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Named
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/02864ebd60ac9eb974a1b54b06368d20b0ca3ce5/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt
*/
class ReleaseService @Inject constructor(
@Named("JsonClient") val client: Retrofit.Builder,
) {
suspend fun latest(org: String, repository: String): Release {
return client
.baseUrl("https://api.github.com/")
.build()
.create(GithubApi::class.java)
.getLatest(org, repository)
.let(releaseMapper)
}
}

View file

@ -0,0 +1,102 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater.interactor
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.breezyweather.background.updater.data.ReleaseService
import org.breezyweather.background.updater.model.Release
import org.breezyweather.domain.settings.SettingsManager
import java.util.Date
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt
*/
class GetApplicationRelease @Inject constructor(
@ApplicationContext val context: Context,
val service: ReleaseService,
) {
suspend fun await(
arguments: Arguments,
): Result {
val now = Date().time
val lastChecked = SettingsManager.getInstance(context).appUpdateCheckLastTimestamp
// Limit checks to once every day at most
if (!arguments.forceCheck && now < lastChecked + 1.days.inWholeMilliseconds) {
return Result.NoNewUpdate
}
val release = service.latest(arguments.org, arguments.repository)
SettingsManager.getInstance(context).appUpdateCheckLastTimestamp = now
// Check if latest version is different from current version
val isNewVersion = isNewVersion(
arguments.versionName,
release.version
)
return when {
isNewVersion -> Result.NewUpdate(release)
else -> Result.NoNewUpdate
}
}
private fun isNewVersion(
versionName: String,
versionTag: String,
): Boolean {
// Removes "v" prefixes
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
val oldVersion = versionName.replace("[^\\d.]".toRegex(), "")
val newSemVer = newVersion.split(".").map { it.toInt() }
val oldSemVer = oldVersion.split(".").map { it.toInt() }
oldSemVer.mapIndexed { index, i ->
// Useful in case of pre-releases, where the newer stable version is older than the pre-release
if (newSemVer[index] < i) {
return false
}
if (newSemVer[index] > i) {
return true
}
}
return false
}
data class Arguments(
val versionName: String,
val org: String,
val repository: String,
val forceCheck: Boolean = false,
)
sealed interface Result {
data class NewUpdate(val release: Release) : Result
data object NoNewUpdate : Result
data object OsTooOld : Result
}
}

View file

@ -0,0 +1,59 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.updater.model
import android.os.Build
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/c83037eeab3b180c7b82355331131df6950f5d45/domain/src/main/java/tachiyomi/domain/release/model/Release.kt
*/
/**
* Contains information about the latest release.
*/
data class Release(
val version: String,
val info: String,
val releaseLink: String,
private val assets: List<String>,
) {
/**
* Get download link of latest release from the assets.
* @return download link of latest release.
*/
fun getDownloadLink(): String {
val apkVariant = when (Build.SUPPORTED_ABIS[0]) {
"arm64-v8a" -> "-arm64-v8a"
"armeabi-v7a" -> "-armeabi-v7a"
"x86" -> "-x86"
"x86_64" -> "-x86_64"
else -> ""
}
return assets.find {
it.startsWith("breezy-weather$apkVariant-") && !it.contains("freenet")
} ?: assets[0] // FIXME
}
/**
* Assets class containing download url.
*/
data class Assets(val downloadLink: String)
}

View file

@ -0,0 +1,530 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.weather
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkQuery
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import breezyweather.domain.location.model.Location
import com.google.maps.android.SphericalUtil
import com.google.maps.android.model.LatLng
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import org.breezyweather.BuildConfig
import org.breezyweather.background.updater.AppUpdateChecker
import org.breezyweather.common.bus.EventBus
import org.breezyweather.common.extensions.createFileInCacheDir
import org.breezyweather.common.extensions.getFormattedDate
import org.breezyweather.common.extensions.getIsoFormattedDate
import org.breezyweather.common.extensions.getUriCompat
import org.breezyweather.common.extensions.isOnline
import org.breezyweather.common.extensions.isRunning
import org.breezyweather.common.extensions.setForegroundSafely
import org.breezyweather.common.extensions.withIOContext
import org.breezyweather.common.extensions.workManager
import org.breezyweather.common.options.NotificationStyle
import org.breezyweather.common.source.LocationResult
import org.breezyweather.common.source.RefreshError
import org.breezyweather.common.source.WeatherResult
import org.breezyweather.domain.location.model.getPlace
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.remoteviews.Notifications
import org.breezyweather.remoteviews.presenters.MultiCityWidgetIMP
import org.breezyweather.sources.RefreshHelper
import org.breezyweather.sources.SourceManager
import org.breezyweather.ui.main.utils.RefreshErrorType
import java.io.File
import java.util.Date
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.minutes
/**
* Based on Mihon LibraryUpdateJob
* Licensed under Apache License, Version 2.0
* https://github.com/mihonapp/mihon/blob/88e9fefa59b3f7f77ab3ddcab1b039f81534c83e/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt
*/
@HiltWorker
class WeatherUpdateJob @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val refreshHelper: RefreshHelper,
private val sourceManager: SourceManager,
private val locationRepository: LocationRepository,
private val weatherRepository: WeatherRepository,
private val updateChecker: AppUpdateChecker,
) : CoroutineWorker(context, workerParams) {
private val notifier = WeatherUpdateNotifier(context)
private var locationsToUpdate: List<Location> = mutableListOf()
override suspend fun doWork(): Result {
if (tags.contains(WORK_NAME_AUTO)) {
// Find a running manual worker. If exists, try again later
if (context.workManager.isRunning(WORK_NAME_MANUAL)) {
return Result.retry()
}
}
// Exit early in case there is no network and Android still executes the job
if (!context.isOnline()) {
return Result.retry()
}
setForegroundSafely()
// Set the last update time to now
SettingsManager.getInstance(context).weatherUpdateLastTimestamp = Date().time
val locationFormattedId = inputData.getString(KEY_LOCATION)
addLocationToQueue(locationFormattedId)
return withIOContext {
try {
updateWeatherData()
Result.success()
} catch (e: Exception) {
if (e is CancellationException) {
// Assume success although cancelled
Result.success()
} else {
e.printStackTrace()
Result.failure()
}
} finally {
notifier.cancelProgressNotification()
// if (BuildConfig.FLAVOR != "freenet" && SettingsManager.getInstance(context).isAppUpdateCheckEnabled) {
if ((BuildConfig.FLAVOR != "freenet" && SettingsManager.getInstance(context).isAppUpdateCheckEnabled) ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.M
) {
try {
updateChecker.checkForUpdate(context, forceCheck = false)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val notifier = WeatherUpdateNotifier(context)
return ForegroundInfo(
Notifications.ID_WEATHER_PROGRESS,
notifier.progressNotificationBuilder.build(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
)
}
/**
* Adds list of locations to be updated.
*
* @param locationFormattedId the ID of the location to update, or null if automatic detection.
*/
private suspend fun addLocationToQueue(locationFormattedId: String?) {
locationsToUpdate = if (locationFormattedId != null) {
val location = locationRepository.getLocation(locationFormattedId)
if (location != null) {
listOf(
location.copy(
weather = weatherRepository.getWeatherByLocationId(location.formattedId)
)
)
} else {
emptyList()
}
} else {
val locationList = when {
// Should be getAllLocations(), but some rare users have 100+ locations. No need to refresh all of them
// in that case, they don't actually use them every day, they just add them as "bookmarks"
refreshHelper.isBroadcastSourcesEnabled(context) -> locationRepository.getXLocations(5)
SettingsManager.getInstance(context).isWidgetNotificationEnabled &&
SettingsManager.getInstance(context).widgetNotificationStyle == NotificationStyle.CITIES ->
locationRepository.getXLocations(4)
MultiCityWidgetIMP.isInUse(context) -> locationRepository.getXLocations(3)
else -> locationRepository.getXLocations(1)
}
locationList
.map {
it.copy(
weather = weatherRepository.getWeatherByLocationId(it.formattedId)
)
}
.filterIndexed { i, location ->
// Only refresh secondary locations once a day as we only need daily info
i == 0 ||
location.weather?.base?.refreshTime == null ||
location.weather!!.base.refreshTime!!.getIsoFormattedDate(location) <
Date().getFormattedDate("yyyy-MM-dd")
}
.toMutableList()
}
}
/**
* Method that updates weather in [locationsToUpdate]. It's called in a background thread, so it's safe
* to do heavy operations or network calls here.
* For each weather it calls [updateLocation] and updates the notification showing the current
* progress.
*
* @return an observable delivering the progress of each update.
*/
private suspend fun updateWeatherData() {
val progressCount = AtomicInteger(0)
val currentlyUpdatingLocation = CopyOnWriteArrayList<Location>()
val newUpdates = CopyOnWriteArrayList<Pair<Location, Location>>()
val skippedUpdates = CopyOnWriteArrayList<Pair<Location, String?>>()
val failedUpdates = CopyOnWriteArrayList<Pair<Location, String?>>()
/**
* Update coordinates if locations to update contains a current location
*/
val updateCoordinatesErrors = if (locationsToUpdate.any { it.isCurrentPosition }) {
updateCoordinates()
} else {
emptyList()
}
locationsToUpdate.forEach { location ->
withUpdateNotification(
currentlyUpdatingLocation,
progressCount,
location
) {
// TODO: Implement this, its a good idea
/*if (location.updateStrategy != UpdateStrategy.ALWAYS_UPDATE) {
skippedUpdates.add(location to context.getString(R.string.skipped_reason_not_always_update))
} else {*/
try {
val locationResult = updateLocation(location)
locationResult.errors.forEach {
val shortMessage = it.getMessage(context, sourceManager)
if (it.error != RefreshErrorType.NETWORK_UNAVAILABLE &&
it.error != RefreshErrorType.SERVER_TIMEOUT
) {
failedUpdates.add(locationResult.location to shortMessage)
} else {
skippedUpdates.add(locationResult.location to shortMessage)
}
}
if (!locationResult.location.isUsable) {
// Report coordinate update errors only if we cant re-use last known coordinates
updateCoordinatesErrors.forEach {
val shortMessage = it.getMessage(context, sourceManager)
failedUpdates.add(locationResult.location to shortMessage)
}
}
if (locationResult.location.isUsable && !locationResult.location.needsGeocodeRefresh) {
val ignoreCaching = SphericalUtil.computeDistanceBetween(
LatLng(locationResult.location.latitude, locationResult.location.longitude),
LatLng(location.latitude, location.longitude)
) > RefreshHelper.CACHING_DISTANCE_LIMIT
val weatherResult = updateWeather(
locationResult.location,
location.longitude != locationResult.location.longitude ||
location.latitude != locationResult.location.latitude,
ignoreCaching
)
newUpdates.add(
location to locationResult.location.copy(weather = weatherResult.weather)
)
weatherResult.errors.forEach {
failedUpdates.add(location to it.getMessage(context, sourceManager))
}
}
} catch (e: Throwable) {
e.printStackTrace()
val errorMessage = if (e.message.isNullOrEmpty()) {
context.getString(RefreshErrorType.DATA_REFRESH_FAILED.shortMessage)
} else {
e.message
}
failedUpdates.add(location to errorMessage)
}
// }
}
}
notifier.cancelProgressNotification()
if (newUpdates.isNotEmpty()) {
// We updated at least one location, so we need to reload location list and make some post-actions
val locationList = locationRepository.getAllLocations().toMutableList()
for (i in locationList.indices) {
locationList[i] = locationList[i].copy(
weather = weatherRepository.getWeatherByLocationId(locationList[i].formattedId)
)
}
// Update widgets and notification-widget
refreshHelper.updateWidgetIfNecessary(context, locationList)
refreshHelper.updateNotificationIfNecessary(context, locationList)
// Update shortcuts
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
refreshHelper.refreshShortcuts(applicationContext, locationList)
}
val location = locationList[0]
val indexOfFirstLocation = newUpdates.firstOrNull { it.first.formattedId == location.formattedId }
// Send alert and precipitation for the first location
if (indexOfFirstLocation != null) {
Notifications.checkAndSendAlert(
applicationContext,
location,
locationsToUpdate.firstOrNull { it.formattedId == location.formattedId }?.weather
)
Notifications.checkAndSendPrecipitation(applicationContext, location)
}
refreshHelper.broadcastDataIfNecessary(
context,
locationList,
newUpdates.map { it.first.formattedId }.toTypedArray()
)
// Inform main activity that we updated location
newUpdates.forEach {
EventBus.instance
.with(Location::class.java)
.postValue(it.second)
}
}
if (failedUpdates.isNotEmpty()) {
val errorFile = writeErrorFile(failedUpdates)
notifier.showUpdateErrorNotification(
failedUpdates.groupBy { it.first }.size,
errorFile.getUriCompat(context)
)
}
/*if (skippedUpdates.isNotEmpty()) {
notifier.showUpdateSkippedNotification(skippedUpdates.size)
}*/
}
/**
* Updates the current location coordinates.
*
* @return errors if any
*/
private suspend fun updateCoordinates(): List<RefreshError> {
return refreshHelper.updateCurrentCoordinates(context, true)
}
/**
* Updates the location with updated coordinates and reverse geocoding.
*
* @param location the location to update.
* @return location updated.
*/
private suspend fun updateLocation(location: Location): LocationResult {
return refreshHelper.getLocation(context, location)
}
/**
* Updates the weather for the given location and adds them to the database.
*
* @param location the location to update.
* @return weather.
*/
private suspend fun updateWeather(
location: Location,
coordinatesChanged: Boolean,
ignoreCaching: Boolean,
): WeatherResult {
return refreshHelper.getWeather(
context,
location,
coordinatesChanged,
ignoreCaching
)
}
private suspend fun withUpdateNotification(
updatingLocation: CopyOnWriteArrayList<Location>,
completed: AtomicInteger,
location: Location,
block: suspend () -> Unit,
) {
coroutineScope {
ensureActive()
updatingLocation.add(location)
notifier.showProgressNotification(
updatingLocation,
completed.get(),
locationsToUpdate.size
)
block()
ensureActive()
updatingLocation.remove(location)
completed.getAndIncrement()
notifier.showProgressNotification(
updatingLocation,
completed.get(),
locationsToUpdate.size
)
}
}
/**
* Writes basic file of update errors to cache dir.
*/
private fun writeErrorFile(errors: List<Pair<Location, String?>>): File {
try {
if (errors.isNotEmpty()) {
val file = context.createFileInCacheDir("breezyweather_update_errors.txt")
file.bufferedWriter().use { out ->
out.write("Errors during refresh\n\n")
// Error file format:
// ! Location
// - Error
errors.groupBy({ it.first }, { it.second }).forEach { (location, errors) ->
out.write("\n! ${location.getPlace(context, showCurrentPositionInPriority = true)}\n")
errors.forEach {
out.write(" - $it\n")
}
}
}
return file
}
} catch (_: Exception) {}
return File("")
}
companion object {
private const val TAG = "WeatherUpdate"
private const val WORK_NAME_AUTO = "WeatherUpdate-auto"
private const val WORK_NAME_MANUAL = "WeatherUpdate-manual"
/**
* Key for location to update.
*/
private const val KEY_LOCATION = "location"
private const val MINUTES_PER_HOUR: Long = 60
private const val BACKOFF_DELAY_MINUTES: Long = 10
fun cancelAllWorks(context: Context) {
context.workManager.cancelAllWorkByTag(TAG)
}
fun setupTask(
context: Context,
) {
val settings = SettingsManager.getInstance(context)
val pollingRate = settings.updateInterval.interval
if (pollingRate != null && pollingRate > 15.minutes) {
val constraints = Constraints(
requiredNetworkType = NetworkType.CONNECTED,
requiresBatteryNotLow = settings.ignoreUpdatesWhenBatteryLow
)
val request = PeriodicWorkRequestBuilder<WeatherUpdateJob>(
pollingRate.inWholeMinutes,
TimeUnit.MINUTES,
BACKOFF_DELAY_MINUTES,
TimeUnit.MINUTES
)
.addTag(TAG)
.addTag(WORK_NAME_AUTO)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
context.workManager.enqueueUniquePeriodicWork(
WORK_NAME_AUTO,
ExistingPeriodicWorkPolicy.UPDATE,
request
)
} else {
context.workManager.cancelUniqueWork(WORK_NAME_AUTO)
}
}
fun startNow(
context: Context,
location: Location? = null,
): Boolean {
val wm = context.workManager
if (wm.isRunning(TAG)) {
// Already running either as a scheduled or manual job
return false
}
val inputData = workDataOf(
KEY_LOCATION to location?.formattedId
)
val request = OneTimeWorkRequestBuilder<WeatherUpdateJob>()
.addTag(TAG)
.addTag(WORK_NAME_MANUAL)
.setInputData(inputData)
.build()
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
return true
}
fun stop(context: Context) {
val wm = context.workManager
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
.addStates(listOf(WorkInfo.State.RUNNING))
.build()
wm.getWorkInfos(workQuery).get()
// Should only return one work but just in case
.forEach {
wm.cancelWorkById(it.id)
// Re-enqueue cancelled scheduled work
if (it.tags.contains(WORK_NAME_AUTO)) {
setupTask(context)
}
}
}
}
}

View file

@ -0,0 +1,112 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.background.weather
import android.content.Context
import android.net.Uri
import androidx.core.app.NotificationCompat
import breezyweather.domain.location.model.Location
import org.breezyweather.R
import org.breezyweather.background.receiver.NotificationReceiver
import org.breezyweather.common.extensions.cancelNotification
import org.breezyweather.common.extensions.chop
import org.breezyweather.common.extensions.notificationBuilder
import org.breezyweather.common.extensions.notify
import org.breezyweather.remoteviews.Notifications
/**
* Based on Mihon
* Apache License, Version 2.0
* https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt
*/
class WeatherUpdateNotifier(
private val context: Context,
) {
/**
* Pending intent of action that cancels the weather update
*/
private val cancelIntent by lazy {
NotificationReceiver.cancelWeatherUpdatePendingBroadcast(context)
}
/**
* Cached progress notification to avoid creating a lot.
*/
val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_BACKGROUND) {
setContentTitle(context.getString(R.string.app_name))
setSmallIcon(R.drawable.ic_running_in_background)
setOngoing(true)
setOnlyAlertOnce(true)
addAction(R.drawable.ic_close, context.getString(android.R.string.cancel), cancelIntent)
}
}
/**
* Shows the notification containing the currently updating manga and the progress.
*
* @param locations the manga that are being updated.
* @param current the current progress.
* @param total the total progress.
*/
fun showProgressNotification(locations: List<Location>, current: Int, total: Int) {
val updatingText = locations.joinToString("\n") { it.city.chop(40) }
progressNotificationBuilder
.setContentTitle(
context.getString(R.string.notification_updating_weather_data, current, total)
)
.setStyle(NotificationCompat.BigTextStyle().bigText(updatingText))
context.notify(
Notifications.ID_WEATHER_PROGRESS,
progressNotificationBuilder
.setProgress(total, current, false)
.build()
)
}
/**
* Shows notification containing update entries that failed with action to open full log.
*
* @param failed Number of entries that failed to update.
* @param uri Uri for error log file containing all titles that failed.
*/
fun showUpdateErrorNotification(failed: Int, uri: Uri) {
if (failed == 0) {
return
}
context.notify(
Notifications.ID_WEATHER_ERROR,
Notifications.CHANNEL_BACKGROUND
) {
setContentTitle(context.resources.getString(R.string.notification_update_error, failed))
setContentText(context.getString(R.string.action_show_errors))
setSmallIcon(R.drawable.ic_running_in_background)
setContentIntent(NotificationReceiver.openErrorLogPendingActivity(context, uri))
}
}
/**
* Cancels the progress notification.
*/
fun cancelProgressNotification() {
context.cancelNotification(Notifications.ID_WEATHER_PROGRESS)
}
}

View file

@ -0,0 +1,51 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.actionmodecallback
import android.graphics.Rect
import android.os.Build
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.M)
internal class BreezyFloatingTextActionModeCallback(
private val callback: BreezyTextActionModeCallback,
) : ActionMode.Callback2() {
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}
override fun onDestroyActionMode(mode: ActionMode?) {
callback.onDestroyActionMode(mode)
}
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) {
val rect = callback.rect
outRect?.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt())
}
}

View file

@ -0,0 +1,41 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.actionmodecallback
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
internal class BreezyPrimaryTextActionModeCallback(
private val callback: BreezyTextActionModeCallback,
) : ActionMode.Callback {
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}
override fun onDestroyActionMode(mode: ActionMode?) {
callback.onDestroyActionMode(mode)
}
}

View file

@ -0,0 +1,45 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.actionmodecallback
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
/**
* A selection container with the following options:
* - Copy
* - Select all
* - Translate
* - Share
*/
@Composable
fun BreezySelectionContainer(
content: @Composable () -> Unit,
) {
val view = LocalView.current
val breezyTextToolbar = remember { BreezyTextToolbar(view = view) }
CompositionLocalProvider(LocalTextToolbar provides breezyTextToolbar) {
SelectionContainer {
content()
}
}
}

View file

@ -0,0 +1,109 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.actionmodecallback
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.geometry.Rect
import org.breezyweather.R
internal class BreezyTextActionModeCallback(
val onActionModeDestroy: ((mode: ActionMode?) -> Unit)? = null,
var rect: Rect = Rect.Zero,
var onCopyRequested: (() -> Unit)? = null,
var onSelectAllRequested: (() -> Unit)? = null,
var onTranslateRequested: (() -> Unit)? = null,
var onShareRequested: (() -> Unit)? = null,
) : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
requireNotNull(menu) { "onCreateActionMode requires a non-null menu" }
requireNotNull(mode) { "onCreateActionMode requires a non-null mode" }
onCopyRequested?.let { addMenuItem(menu, MenuItemOption.Copy) }
onSelectAllRequested?.let { addMenuItem(menu, MenuItemOption.SelectAll) }
onTranslateRequested?.let { addMenuItem(menu, MenuItemOption.Translate) }
onShareRequested?.let { addMenuItem(menu, MenuItemOption.Share) }
return true
}
// this method is called to populate new menu items when the actionMode was invalidated
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
if (mode == null || menu == null) return false
updateMenuItems(menu)
// should return true so that new menu items are populated
return true
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
when (item!!.itemId) {
MenuItemOption.Copy.id -> onCopyRequested?.invoke()
MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke()
MenuItemOption.Translate.id -> onTranslateRequested?.invoke()
MenuItemOption.Share.id -> onShareRequested?.invoke()
else -> return false
}
mode?.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
onActionModeDestroy?.invoke(mode)
}
@VisibleForTesting
internal fun updateMenuItems(menu: Menu) {
addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested)
addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Translate, onTranslateRequested)
addOrRemoveMenuItem(menu, MenuItemOption.Share, onShareRequested)
}
internal fun addMenuItem(menu: Menu, item: MenuItemOption) {
menu
.add(0, item.id, item.order, item.titleResource)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
private fun addOrRemoveMenuItem(menu: Menu, item: MenuItemOption, callback: (() -> Unit)?) {
when {
callback != null && menu.findItem(item.id) == null -> addMenuItem(menu, item)
callback == null && menu.findItem(item.id) != null -> menu.removeItem(item.id)
}
}
}
internal enum class MenuItemOption(val id: Int) {
Copy(0),
SelectAll(1),
Translate(2),
Share(3),
;
val titleResource: Int
get() =
when (this) {
Copy -> android.R.string.copy
SelectAll -> android.R.string.selectAll
Translate -> R.string.action_translate
Share -> R.string.action_share
}
/** This item will be shown before all items that have order greater than this value. */
val order = id
}

View file

@ -0,0 +1,173 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.actionmodecallback
import android.content.ClipData
import android.content.Intent
import android.os.Build
import android.view.ActionMode
import android.view.View
import androidx.annotation.RequiresApi
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import org.breezyweather.R
import org.breezyweather.common.extensions.clipboardManager
import org.breezyweather.common.utils.helpers.SnackbarHelper
internal class BreezyTextToolbar(
private val view: View,
) : TextToolbar {
private var actionMode: ActionMode? = null
private val textActionModeCallback: BreezyTextActionModeCallback =
BreezyTextActionModeCallback(onActionModeDestroy = { actionMode = null })
override var status: TextToolbarStatus = TextToolbarStatus.Hidden
private set
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
onAutofillRequested: (() -> Unit)?,
) {
textActionModeCallback.rect = rect
textActionModeCallback.onCopyRequested = onCopyRequested
textActionModeCallback.onSelectAllRequested = onSelectAllRequested
textActionModeCallback.onTranslateRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
{
// Get selected text by copying it, then restore the previous clip
val clipboardManager = view.context.clipboardManager
val previousClipboard = clipboardManager.primaryClip
onCopyRequested?.invoke()
val text = clipboardManager.text
if (previousClipboard != null) {
clipboardManager.setPrimaryClip(previousClipboard)
} else {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " "))
}
val intent = Intent().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
action = Intent.ACTION_TRANSLATE
putExtra(Intent.EXTRA_TEXT, text.trim())
} else {
action = Intent.ACTION_PROCESS_TEXT
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, text.trim())
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
}
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
try {
view.context.startActivity(Intent.createChooser(intent, ""))
} catch (e: Exception) {
SnackbarHelper.showSnackbar(view.context.getString(R.string.action_translate_no_app))
}
}
} else {
null
}
textActionModeCallback.onShareRequested = {
// Get selected text by copying it, then restore the previous clip
val clipboardManager = view.context.clipboardManager
val previousClipboard = clipboardManager.primaryClip
onCopyRequested?.invoke()
val text = clipboardManager.text
if (previousClipboard != null) {
clipboardManager.setPrimaryClip(previousClipboard)
} else {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " "))
}
val intent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, view.context.getString(R.string.app_name))
putExtra(Intent.EXTRA_TEXT, text.trim())
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
try {
view.context.startActivity(Intent.createChooser(intent, ""))
} catch (e: Exception) {
SnackbarHelper.showSnackbar(view.context.getString(R.string.action_share_no_app))
}
}
if (actionMode == null) {
status = TextToolbarStatus.Shown
actionMode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
TextToolbarHelperMethods.startActionMode(
view,
BreezyFloatingTextActionModeCallback(textActionModeCallback),
ActionMode.TYPE_FLOATING
)
} else {
view.startActionMode(BreezyPrimaryTextActionModeCallback(textActionModeCallback))
}
} else {
actionMode?.invalidate()
}
}
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
) {
showMenu(
rect = rect,
onCopyRequested = onCopyRequested,
onPasteRequested = onPasteRequested,
onCutRequested = onCutRequested,
onSelectAllRequested = onSelectAllRequested
)
}
override fun hide() {
status = TextToolbarStatus.Hidden
actionMode?.finish()
actionMode = null
}
}
/**
* This class is here to ensure that the classes that use this API will get verified and can be AOT
* compiled. It is expected that this class will soft-fail verification, but the classes which use
* this method will pass.
*/
@RequiresApi(Build.VERSION_CODES.M)
internal object TextToolbarHelperMethods {
@RequiresApi(Build.VERSION_CODES.M)
fun startActionMode(
view: View,
actionModeCallback: ActionMode.Callback,
type: Int,
): ActionMode? {
return view.startActionMode(actionModeCallback, type)
}
@RequiresApi(Build.VERSION_CODES.M)
fun invalidateContentRect(actionMode: ActionMode) {
actionMode.invalidateContentRect()
}
}

View file

@ -0,0 +1,93 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.activities
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.enableEdgeToEdge
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Lifecycle
import org.breezyweather.BreezyWeather
import org.breezyweather.common.extensions.isDarkMode
import org.breezyweather.common.extensions.setSystemBarStyle
import org.breezyweather.common.snackbar.SnackbarContainer
abstract class BreezyActivity : AppCompatActivity() {
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
window.setSystemBarStyle(!isDarkMode)
}
BreezyWeather.instance.addActivity(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
BreezyWeather.instance.setTopActivity(this)
}
@CallSuper
override fun onResume() {
super.onResume()
BreezyWeather.instance.setTopActivity(this)
}
@CallSuper
override fun onPause() {
super.onPause()
BreezyWeather.instance.checkToCleanTopActivity(this)
}
@CallSuper
override fun onDestroy() {
super.onDestroy()
BreezyWeather.instance.removeActivity(this)
}
fun updateLocalNightMode(expectedLightTheme: Boolean) {
getDelegate().localNightMode = if (expectedLightTheme) {
AppCompatDelegate.MODE_NIGHT_NO
} else {
AppCompatDelegate.MODE_NIGHT_YES
}
}
open val snackbarContainer: SnackbarContainer
get() = SnackbarContainer(
this,
findViewById<ViewGroup>(android.R.id.content).getChildAt(0) as ViewGroup,
true
)
fun provideSnackbarContainer(): SnackbarContainer = snackbarContainer
val isActivityCreated: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
val isActivityStarted: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
val isActivityResumed: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}

View file

@ -0,0 +1,43 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.activities
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import org.breezyweather.common.snackbar.SnackbarContainer
open class BreezyFragment : Fragment() {
var isFragmentViewCreated = false
private set
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isFragmentViewCreated = true
}
val isFragmentCreated: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)
val isFragmentStarted: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
val isFragmentResumed: Boolean
get() = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
val snackbarContainer: SnackbarContainer
get() = SnackbarContainer(this, (requireView() as ViewGroup), true)
}

View file

@ -0,0 +1,32 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.activities
import android.app.Application
import androidx.lifecycle.AndroidViewModel
// TODO: Issue with getter on application when converted to Kotlin
open class BreezyViewModel(
application: Application,
) : AndroidViewModel(application) {
private var mNewInstance = true
fun checkIsNewInstance(): Boolean {
val result = mNewInstance
mNewInstance = false
return result
}
}

View file

@ -0,0 +1,108 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.activities.livedata
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import org.breezyweather.common.bus.MyObserverWrapper
class BusLiveData<T>(
private val mainHandler: Handler,
) : MutableLiveData<T>() {
companion object {
const val START_VERSION = -1
}
private val wrapperMap = HashMap<Observer<in T>, MyObserverWrapper<T>>()
internal var version = START_VERSION
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
runOnMainThread {
innerObserver(owner, MyObserverWrapper(this, observer, version))
}
}
fun observeAutoRemove(owner: LifecycleOwner, observer: Observer<in T>) {
runOnMainThread {
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
removeObserver(observer)
}
})
innerObserver(owner, MyObserverWrapper(this, observer, version))
}
}
fun observeStickily(owner: LifecycleOwner, observer: Observer<in T>) {
runOnMainThread {
innerObserver(owner, MyObserverWrapper(this, observer, START_VERSION))
}
}
private fun innerObserver(owner: LifecycleOwner, wrapper: MyObserverWrapper<T>) {
wrapperMap[wrapper.observer] = wrapper
super.observe(owner, wrapper)
}
override fun observeForever(observer: Observer<in T>) {
runOnMainThread {
innerObserverForever(MyObserverWrapper(this, observer, version))
}
}
fun observeStickilyForever(observer: Observer<in T>) {
runOnMainThread {
innerObserverForever(MyObserverWrapper(this, observer, START_VERSION))
}
}
private fun innerObserverForever(wrapper: MyObserverWrapper<T>) {
wrapperMap[wrapper.observer] = wrapper
super.observeForever(wrapper)
}
override fun removeObserver(observer: Observer<in T>) {
runOnMainThread {
val wrapper = wrapperMap.remove(observer)
if (wrapper != null) {
super.removeObserver(wrapper)
}
}
}
override fun setValue(value: T) {
++version
super.setValue(value)
}
override fun postValue(value: T) {
runOnMainThread { setValue(value) }
}
private fun runOnMainThread(r: Runnable) {
if (Looper.getMainLooper().thread === Thread.currentThread()) {
r.run()
} else {
mainHandler.post(r)
}
}
}

View file

@ -0,0 +1,39 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.activities.livedata
import androidx.lifecycle.MutableLiveData
class EqualtableLiveData<T>(
value: T? = null,
) : MutableLiveData<T>(value) {
override fun setValue(value: T) {
if (value == this.value) {
return
}
super.setValue(value)
}
override fun postValue(value: T) {
// this.value is a volatile value.
if (value == this.value) {
return
}
super.postValue(value)
}
}

View file

@ -0,0 +1,49 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.bus
import android.os.Handler
import android.os.Looper
import org.breezyweather.common.activities.livedata.BusLiveData
class EventBus private constructor() {
companion object {
val instance: EventBus by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
EventBus()
}
}
private val liveDataMap = HashMap<String, BusLiveData<Any>>()
private val mainHandler = Handler(Looper.getMainLooper())
fun <T> with(type: Class<T>): BusLiveData<T> {
val key = key(type = type)
if (!liveDataMap.containsKey(key)) {
liveDataMap[key] = BusLiveData(mainHandler)
}
return liveDataMap[key] as BusLiveData<T>
}
fun remove(type: Class<*>) {
liveDataMap.remove(key(type))
}
private fun <T> key(type: Class<T>) = type.name
}

View file

@ -0,0 +1,40 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.bus
import androidx.lifecycle.Observer
import org.breezyweather.common.activities.livedata.BusLiveData
import java.lang.ref.WeakReference
internal class MyObserverWrapper<T> internal constructor(
host: BusLiveData<T>,
internal val observer: Observer<in T>,
private var version: Int,
) : Observer<T> {
private val host = WeakReference(host)
override fun onChanged(value: T) {
host.get()?.let {
if (version >= it.version) {
return
}
version = it.version
observer.onChanged(value)
}
}
}

View file

@ -0,0 +1,275 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.di
import android.content.Context
import android.os.Build
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import breezyweather.data.AlertSeverityColumnAdapter
import breezyweather.data.Alerts
import breezyweather.data.AndroidDatabaseHandler
import breezyweather.data.Dailys
import breezyweather.data.Database
import breezyweather.data.DatabaseHandler
import breezyweather.data.DistanceColumnAdapter
import breezyweather.data.DurationColumnAdapter
import breezyweather.data.Hourlys
import breezyweather.data.Locations
import breezyweather.data.Minutelys
import breezyweather.data.Normals
import breezyweather.data.PollenConcentrationColumnAdapter
import breezyweather.data.PollutantConcentrationColumnAdapter
import breezyweather.data.PrecipitationColumnAdapter
import breezyweather.data.PressureColumnAdapter
import breezyweather.data.RatioColumnAdapter
import breezyweather.data.SpeedColumnAdapter
import breezyweather.data.TemperatureColumnAdapter
import breezyweather.data.TimeZoneColumnAdapter
import breezyweather.data.WeatherCodeColumnAdapter
import breezyweather.data.Weathers
import breezyweather.data.location.LocationRepository
import breezyweather.data.weather.WeatherRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import org.breezyweather.BuildConfig
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class DbModule {
@Provides
@Singleton
fun provideSqlDriver(@ApplicationContext context: Context): SqlDriver {
return AndroidSqliteDriver(
schema = Database.Schema,
context = context,
name = "breezyweather.db",
factory = if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Support database inspector in Android Studio
FrameworkSQLiteOpenHelperFactory()
} else {
RequerySQLiteOpenHelperFactory()
},
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
setPragma(db, "foreign_keys = ON")
setPragma(db, "journal_mode = WAL")
setPragma(db, "synchronous = NORMAL")
}
private fun setPragma(db: SupportSQLiteDatabase, pragma: String) {
val cursor = db.query("PRAGMA $pragma")
cursor.moveToFirst()
cursor.close()
}
}
)
}
@Provides
@Singleton
fun provideDatabase(driver: SqlDriver): Database {
return Database(
driver,
locationsAdapter = Locations.Adapter(
timezoneAdapter = TimeZoneColumnAdapter
),
weathersAdapter = Weathers.Adapter(
weather_codeAdapter = WeatherCodeColumnAdapter,
temperatureAdapter = TemperatureColumnAdapter,
temperature_source_feels_likeAdapter = TemperatureColumnAdapter,
temperature_apparentAdapter = TemperatureColumnAdapter,
temperature_wind_chillAdapter = TemperatureColumnAdapter,
humidexAdapter = TemperatureColumnAdapter,
wind_speedAdapter = SpeedColumnAdapter,
wind_gustsAdapter = SpeedColumnAdapter,
pm25Adapter = PollutantConcentrationColumnAdapter,
pm10Adapter = PollutantConcentrationColumnAdapter,
so2Adapter = PollutantConcentrationColumnAdapter,
no2Adapter = PollutantConcentrationColumnAdapter,
o3Adapter = PollutantConcentrationColumnAdapter,
coAdapter = PollutantConcentrationColumnAdapter,
relative_humidityAdapter = RatioColumnAdapter,
dew_pointAdapter = TemperatureColumnAdapter,
pressureAdapter = PressureColumnAdapter,
visibilityAdapter = DistanceColumnAdapter,
cloud_coverAdapter = RatioColumnAdapter,
ceilingAdapter = DistanceColumnAdapter
),
dailysAdapter = Dailys.Adapter(
daytime_weather_codeAdapter = WeatherCodeColumnAdapter,
daytime_temperatureAdapter = TemperatureColumnAdapter,
daytime_temperature_source_feels_likeAdapter = TemperatureColumnAdapter,
daytime_temperature_apparentAdapter = TemperatureColumnAdapter,
daytime_temperature_wind_chillAdapter = TemperatureColumnAdapter,
daytime_humidexAdapter = TemperatureColumnAdapter,
daytime_total_precipitationAdapter = PrecipitationColumnAdapter,
daytime_thunderstorm_precipitationAdapter = PrecipitationColumnAdapter,
daytime_rain_precipitationAdapter = PrecipitationColumnAdapter,
daytime_snow_precipitationAdapter = PrecipitationColumnAdapter,
daytime_ice_precipitationAdapter = PrecipitationColumnAdapter,
daytime_total_precipitation_probabilityAdapter = RatioColumnAdapter,
daytime_thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter,
daytime_rain_precipitation_probabilityAdapter = RatioColumnAdapter,
daytime_snow_precipitation_probabilityAdapter = RatioColumnAdapter,
daytime_ice_precipitation_probabilityAdapter = RatioColumnAdapter,
daytime_total_precipitation_durationAdapter = DurationColumnAdapter,
daytime_thunderstorm_precipitation_durationAdapter = DurationColumnAdapter,
daytime_rain_precipitation_durationAdapter = DurationColumnAdapter,
daytime_snow_precipitation_durationAdapter = DurationColumnAdapter,
daytime_ice_precipitation_durationAdapter = DurationColumnAdapter,
daytime_wind_speedAdapter = SpeedColumnAdapter,
daytime_wind_gustsAdapter = SpeedColumnAdapter,
nighttime_temperatureAdapter = TemperatureColumnAdapter,
nighttime_temperature_source_feels_likeAdapter = TemperatureColumnAdapter,
nighttime_temperature_apparentAdapter = TemperatureColumnAdapter,
nighttime_temperature_wind_chillAdapter = TemperatureColumnAdapter,
nighttime_humidexAdapter = TemperatureColumnAdapter,
nighttime_weather_codeAdapter = WeatherCodeColumnAdapter,
nighttime_total_precipitationAdapter = PrecipitationColumnAdapter,
nighttime_thunderstorm_precipitationAdapter = PrecipitationColumnAdapter,
nighttime_rain_precipitationAdapter = PrecipitationColumnAdapter,
nighttime_snow_precipitationAdapter = PrecipitationColumnAdapter,
nighttime_ice_precipitationAdapter = PrecipitationColumnAdapter,
nighttime_total_precipitation_probabilityAdapter = RatioColumnAdapter,
nighttime_thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter,
nighttime_rain_precipitation_probabilityAdapter = RatioColumnAdapter,
nighttime_snow_precipitation_probabilityAdapter = RatioColumnAdapter,
nighttime_ice_precipitation_probabilityAdapter = RatioColumnAdapter,
nighttime_total_precipitation_durationAdapter = DurationColumnAdapter,
nighttime_thunderstorm_precipitation_durationAdapter = DurationColumnAdapter,
nighttime_rain_precipitation_durationAdapter = DurationColumnAdapter,
nighttime_snow_precipitation_durationAdapter = DurationColumnAdapter,
nighttime_ice_precipitation_durationAdapter = DurationColumnAdapter,
nighttime_wind_speedAdapter = SpeedColumnAdapter,
nighttime_wind_gustsAdapter = SpeedColumnAdapter,
degree_day_heatingAdapter = TemperatureColumnAdapter,
degree_day_coolingAdapter = TemperatureColumnAdapter,
pm25Adapter = PollutantConcentrationColumnAdapter,
pm10Adapter = PollutantConcentrationColumnAdapter,
so2Adapter = PollutantConcentrationColumnAdapter,
no2Adapter = PollutantConcentrationColumnAdapter,
o3Adapter = PollutantConcentrationColumnAdapter,
coAdapter = PollutantConcentrationColumnAdapter,
alderAdapter = PollenConcentrationColumnAdapter,
ashAdapter = PollenConcentrationColumnAdapter,
birchAdapter = PollenConcentrationColumnAdapter,
chestnutAdapter = PollenConcentrationColumnAdapter,
cypressAdapter = PollenConcentrationColumnAdapter,
grassAdapter = PollenConcentrationColumnAdapter,
hazelAdapter = PollenConcentrationColumnAdapter,
hornbeamAdapter = PollenConcentrationColumnAdapter,
lindenAdapter = PollenConcentrationColumnAdapter,
moldAdapter = PollenConcentrationColumnAdapter,
mugwortAdapter = PollenConcentrationColumnAdapter,
oakAdapter = PollenConcentrationColumnAdapter,
oliveAdapter = PollenConcentrationColumnAdapter,
planeAdapter = PollenConcentrationColumnAdapter,
plantainAdapter = PollenConcentrationColumnAdapter,
poplarAdapter = PollenConcentrationColumnAdapter,
ragweedAdapter = PollenConcentrationColumnAdapter,
sorrelAdapter = PollenConcentrationColumnAdapter,
treeAdapter = PollenConcentrationColumnAdapter,
urticaceaeAdapter = PollenConcentrationColumnAdapter,
willowAdapter = PollenConcentrationColumnAdapter,
sunshine_durationAdapter = DurationColumnAdapter,
relative_humidity_averageAdapter = RatioColumnAdapter,
relative_humidity_minAdapter = RatioColumnAdapter,
relative_humidity_maxAdapter = RatioColumnAdapter,
dewpoint_averageAdapter = TemperatureColumnAdapter,
dewpoint_minAdapter = TemperatureColumnAdapter,
dewpoint_maxAdapter = TemperatureColumnAdapter,
pressure_averageAdapter = PressureColumnAdapter,
pressure_maxAdapter = PressureColumnAdapter,
pressure_minAdapter = PressureColumnAdapter,
cloud_cover_averageAdapter = RatioColumnAdapter,
cloud_cover_minAdapter = RatioColumnAdapter,
cloud_cover_maxAdapter = RatioColumnAdapter,
visibility_averageAdapter = DistanceColumnAdapter,
visibility_maxAdapter = DistanceColumnAdapter,
visibility_minAdapter = DistanceColumnAdapter
),
hourlysAdapter = Hourlys.Adapter(
weather_codeAdapter = WeatherCodeColumnAdapter,
temperatureAdapter = TemperatureColumnAdapter,
temperature_source_feels_likeAdapter = TemperatureColumnAdapter,
temperature_apparentAdapter = TemperatureColumnAdapter,
temperature_wind_chillAdapter = TemperatureColumnAdapter,
humidexAdapter = TemperatureColumnAdapter,
total_precipitationAdapter = PrecipitationColumnAdapter,
thunderstorm_precipitationAdapter = PrecipitationColumnAdapter,
rain_precipitationAdapter = PrecipitationColumnAdapter,
snow_precipitationAdapter = PrecipitationColumnAdapter,
ice_precipitationAdapter = PrecipitationColumnAdapter,
total_precipitation_probabilityAdapter = RatioColumnAdapter,
thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter,
rain_precipitation_probabilityAdapter = RatioColumnAdapter,
snow_precipitation_probabilityAdapter = RatioColumnAdapter,
ice_precipitation_probabilityAdapter = RatioColumnAdapter,
wind_speedAdapter = SpeedColumnAdapter,
wind_gustsAdapter = SpeedColumnAdapter,
pm25Adapter = PollutantConcentrationColumnAdapter,
pm10Adapter = PollutantConcentrationColumnAdapter,
so2Adapter = PollutantConcentrationColumnAdapter,
no2Adapter = PollutantConcentrationColumnAdapter,
o3Adapter = PollutantConcentrationColumnAdapter,
coAdapter = PollutantConcentrationColumnAdapter,
relative_humidityAdapter = RatioColumnAdapter,
dew_pointAdapter = TemperatureColumnAdapter,
pressureAdapter = PressureColumnAdapter,
cloud_coverAdapter = RatioColumnAdapter,
visibilityAdapter = DistanceColumnAdapter
),
minutelysAdapter = Minutelys.Adapter(
precipitation_intensityAdapter = PrecipitationColumnAdapter
),
alertsAdapter = Alerts.Adapter(
severityAdapter = AlertSeverityColumnAdapter
),
normalsAdapter = Normals.Adapter(
temperature_max_averageAdapter = TemperatureColumnAdapter,
temperature_min_averageAdapter = TemperatureColumnAdapter
)
)
}
@Provides
@Singleton
fun provideDatabaseHandler(db: Database, driver: SqlDriver): DatabaseHandler {
return AndroidDatabaseHandler(db, driver)
}
@Provides
@Singleton
fun provideLocationRepository(databaseHandler: DatabaseHandler): LocationRepository {
return LocationRepository(databaseHandler)
}
@Provides
@Singleton
fun provideWeatherRepository(databaseHandler: DatabaseHandler): WeatherRepository {
return WeatherRepository(databaseHandler)
}
}

View file

@ -0,0 +1,195 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.di
import android.app.Application
import android.os.Build
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.tls.HandshakeCertificates
import org.breezyweather.BreezyWeather
import org.breezyweather.R
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.io.File
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class HttpModule {
@Provides
@Singleton
fun provideOkHttpClient(app: Application, loggingInterceptor: HttpLoggingInterceptor): OkHttpClient {
val client = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
/**
* Add support for Lets encrypt certificate authority on Android < 7.0
*/
try {
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificateIsrgRootX1 = certificateFactory
.generateCertificates(app.resources.openRawResource(R.raw.isrg_root_x1))
.single() as X509Certificate
val certificateIsrgRootX2 = certificateFactory
.generateCertificates(app.resources.openRawResource(R.raw.isrg_root_x2))
.single() as X509Certificate
val certificates = HandshakeCertificates.Builder()
.addTrustedCertificate(certificateIsrgRootX1)
.addTrustedCertificate(certificateIsrgRootX2)
.addPlatformTrustedCertificates()
.build()
OkHttpClient.Builder()
.sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager)
} catch (ignored: Exception) {
OkHttpClient.Builder()
}
} else {
OkHttpClient.Builder()
}
return client
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(45, TimeUnit.SECONDS)
.cache(
Cache(
File(app.cacheDir, "http_cache"), // $0.05 worth of phone storage in 2020
50L * 1024L * 1024L // 50 MiB
)
)
.addInterceptor(loggingInterceptor)
.build()
}
@Provides
@Singleton
fun provideRxJava3CallAdapterFactory(): RxJava3CallAdapterFactory {
return RxJava3CallAdapterFactory.create()
}
@Provides
@Singleton
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BreezyWeather.instance.debugMode) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
@Named("JsonSerializer")
fun provideKotlinxJsonSerializationConverterFactory(): Converter.Factory {
val contentType = "application/json".toMediaType()
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = !BreezyWeather.instance.debugMode
}
return json.asConverterFactory(contentType)
}
@Provides
@Named("JsonClient")
fun provideJsonRetrofitBuilder(
client: OkHttpClient,
@Named("JsonSerializer") jsonConverterFactory: Converter.Factory,
callAdapterFactory: RxJava3CallAdapterFactory,
): Retrofit.Builder {
return Retrofit.Builder()
.client(client)
.addConverterFactory(jsonConverterFactory)
// TODO: We should probably migrate to suspend
// https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05
.addCallAdapterFactory(callAdapterFactory)
}
@Provides
@Singleton
@Named("XmlSerializer")
fun provideKotlinxXmlSerializationConverterFactory(): Converter.Factory {
val contentType = "application/xml".toMediaType()
return XML {
defaultPolicy {
pedantic = false
ignoreUnknownChildren()
}
autoPolymorphic = true
}.asConverterFactory(contentType)
}
@Provides
@Named("XmlClient")
fun provideXmlRetrofitBuilder(
client: OkHttpClient,
@Named("XmlSerializer") xmlConverterFactory: Converter.Factory,
callAdapterFactory: RxJava3CallAdapterFactory,
): Retrofit.Builder {
return Retrofit.Builder()
.client(client)
.addConverterFactory(xmlConverterFactory)
// TODO: We should probably migrate to suspend
// https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05
.addCallAdapterFactory(callAdapterFactory)
}
/*@Provides
@Singleton
@Named("CsvSerializer")
fun provideKotlinxCsvSerializationConverterFactory(): Converter.Factory {
val contentType = "text/csv".toMediaType() // RFC 7111
val csv = Csv {
hasHeaderRecord = true
delimiter = ';'
recordSeparator = "\r\n"
ignoreUnknownColumns = true
}
return csv.asConverterFactory(contentType)
}
@Provides
@Named("CsvClient")
fun provideCsvRetrofitBuilder(
client: OkHttpClient,
@Named("CsvSerializer") csvConverterFactory: Converter.Factory,
callAdapterFactory: RxJava3CallAdapterFactory,
): Retrofit.Builder {
return Retrofit.Builder()
.client(client)
.addConverterFactory(csvConverterFactory)
// TODO: We should probably migrate to suspend
// https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05
.addCallAdapterFactory(callAdapterFactory)
}*/
}

View file

@ -0,0 +1,32 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.reactivex.rxjava3.disposables.CompositeDisposable
@InstallIn(SingletonComponent::class)
@Module
class RxModule {
@Provides
fun provideCompositeDisposable(): CompositeDisposable {
return CompositeDisposable()
}
}

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class ApiKeyMissingException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class ApiLimitReachedException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class ApiUnauthorizedException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class InvalidLocationException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class InvalidOrIncompleteDataException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class LocationAccessOffException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class LocationException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class LocationSearchException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class MissingPermissionLocationBackgroundException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class MissingPermissionLocationException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class NoNetworkException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class NonFreeNetSourceException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class OutdatedServerDataException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class ParsingException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class ReverseGeocodingException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class SourceNotInstalledException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class UnsupportedFeatureException : Exception()

View file

@ -0,0 +1,19 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.exceptions
class WeatherException : Exception()

View file

@ -0,0 +1,116 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.Manifest
import android.app.UiModeManager
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ShortcutManager
import android.hardware.SensorManager
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.annotation.RawRes
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import com.google.maps.android.data.geojson.GeoJsonParser
import org.breezyweather.domain.settings.SettingsManager
import org.json.JSONObject
import java.io.File
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/162b6397050e1577c113a88e7b7cfe9f98e6a45c/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt
*/
/**
* Checks if the give permission is granted.
*
* @param permission the permission to check.
* @return true if it has permissions.
*/
fun Context.hasPermission(
permission: String,
) = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
/**
* Checks if the notification permission is granted.
*
* @return true if the permission is granted. Always returns true on Android 12 and lower.
*/
val Context.hasNotificationPermission
get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
val Context.clipboardManager: ClipboardManager
get() = getSystemService()!!
val Context.inputMethodManager: InputMethodManager
get() = getSystemService()!!
val Context.locationManager: LocationManager
get() = getSystemService()!!
val Context.powerManager: PowerManager
get() = getSystemService()!!
val Context.sensorManager: SensorManager?
get() = if (SettingsManager.getInstance(this).isGravitySensorEnabled) {
getSystemService()
} else {
null
}
val Context.windowManager: WindowManager?
get() = getSystemService()
val Context.shortcutManager: ShortcutManager?
get() = getSystemService()
val Context.uiModeManager: UiModeManager?
get() = getSystemService()
fun Context.createFileInCacheDir(name: String): File {
val file = File(externalCacheDir, name)
if (file.exists()) {
file.delete()
}
file.createNewFile()
return file
}
fun Context.parseRawGeoJson(@RawRes rawFile: Int): GeoJsonParser {
val text = resources.openRawResource(rawFile).bufferedReader().use { it.readText() }
return GeoJsonParser(JSONObject(text))
}
fun Context.openApplicationDetailsSettings() {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(
Uri.fromParts("package", packageName, null)
)
)
}

View file

@ -0,0 +1,37 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.Main, block = block)
fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job =
launch(Dispatchers.IO, block = block)
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block)
suspend fun <T> withNonCancellableContext(block: suspend CoroutineScope.() -> T) =
withContext(NonCancellable, block)

View file

@ -0,0 +1,43 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.os.Bundle
import android.os.Parcel
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPOutputStream
val Bundle.sizeInBytes: Int
get() {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
return parcel.dataSize().also {
parcel.recycle()
}
}
/**
* Compress a string using GZIP.
*
* @return an UTF-8 encoded byte array.
*/
fun String.gzipCompress(): ByteArray {
val bos = ByteArrayOutputStream()
GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(this) }
return bos.toByteArray()
}

View file

@ -0,0 +1,286 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.annotation.SuppressLint
import android.content.Context
import android.icu.text.DateTimePatternGenerator
import android.icu.text.SimpleDateFormat
import android.icu.util.TimeZone
import android.icu.util.ULocale
import android.os.Build
import android.text.format.DateFormat
import android.text.format.DateUtils
import androidx.annotation.RequiresApi
import breezyweather.domain.location.model.Location
import breezyweather.domain.weather.reference.Month
import org.breezyweather.BreezyWeather
import org.breezyweather.common.options.appearance.CalendarHelper
import org.breezyweather.common.utils.helpers.LogHelper
import org.chickenhook.restrictionbypass.RestrictionBypass
import java.lang.reflect.Method
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Date
import java.util.Locale
val Context.is12Hour: Boolean
get() = !DateFormat.is24HourFormat(this)
@SuppressLint("PrivateApi")
fun Date.getRelativeTime(context: Context): String {
try {
// Reflection allows us to specify the locale
// If we don't, we always have system locale instead of per-app language preference
val getRelativeTimeSpanStringMethod: Method = RestrictionBypass.getMethod(
Class.forName("android.text.format.RelativeDateTimeFormatter"),
"getRelativeTimeSpanString",
Locale::class.java,
java.util.TimeZone::class.java,
Long::class.javaPrimitiveType,
Long::class.javaPrimitiveType,
Long::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
return getRelativeTimeSpanStringMethod.invoke(
null,
context.currentLocale,
java.util.TimeZone.getDefault(),
time,
Date().time,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
) as String
} catch (_: Exception) {
if (BreezyWeather.instance.debugMode) {
LogHelper.log(msg = "Reflection of relative time failed")
}
return DateUtils.getRelativeTimeSpanString(
time,
Date().time,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
) as String
}
}
// Makes the code more readable by not having to do a null check condition
fun Long.toDate(): Date {
return Date(this)
}
fun Date.getFormattedDate(
pattern: String,
location: Location? = null,
context: Context? = null,
withBestPattern: Boolean = false,
): String {
val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
SimpleDateFormat(
if (withBestPattern) {
DateTimePatternGenerator.getInstance(locale).getBestPattern(pattern)
} else {
pattern
},
locale
).apply {
timeZone = location?.timeZone?.let { TimeZone.getTimeZone(it.id) } ?: TimeZone.getDefault()
}.format(this)
} else {
@Suppress("DEPRECATION")
getFormattedDate(pattern, location?.timeZone, locale)
}
}
fun Date.getFormattedTime(
location: Location? = null,
context: Context?,
twelveHour: Boolean,
): String {
return if (twelveHour) {
getFormattedDate("h:mm a", location, context, withBestPattern = true)
} else {
getFormattedDate("HH:mm", location, context)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun LocalTime.getFormattedTime(
locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build(),
twelveHour: Boolean,
): String {
return if (twelveHour) {
format(
DateTimeFormatter.ofPattern(
DateTimePatternGenerator.getInstance(locale).getBestPattern("h:mm a")
).withLocale(locale)
)
} else {
format(DateTimeFormatter.ofPattern("HH:mm").withLocale(locale))
}
}
fun Date.getFormattedShortDayAndMonth(
location: Location,
context: Context?,
): String {
return getFormattedDate("MM-dd", location, context, withBestPattern = true)
}
fun Date.getFormattedDayOfTheMonth(
location: Location,
context: Context?,
): String {
return getFormattedDate("dd", location, context, withBestPattern = true)
}
fun Date.getFormattedMediumDayAndMonth(
location: Location,
context: Context?,
): String {
val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
return getFormattedDate("d MMM", location, context, withBestPattern = true).capitalize(locale)
}
fun Date.getFormattedFullDayAndMonth(
location: Location,
context: Context?,
): String {
val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
return getFormattedDate("d MMMM", location, context, withBestPattern = true).capitalize(locale)
}
fun getShortWeekdayDayMonth(
context: Context?,
): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
DateTimePatternGenerator.getInstance(
context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
).getBestPattern("EEE d MMM")
} else {
"EEE d MMM"
}
}
fun getLongWeekdayDayMonth(
context: Context?,
): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
DateTimePatternGenerator.getInstance(
context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
).getBestPattern("EEEE d MMMM")
} else {
"EEEE d MMMM"
}
}
fun Date.getWeek(location: Location, context: Context?, full: Boolean = false): String {
val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build()
return getFormattedDate(if (full) "EEEE" else "E", location, context).capitalize(locale)
}
fun Date.getHour(location: Location, context: Context): String {
return getFormattedDate(
if (context.is12Hour) "h a" else "H:mm",
location,
context,
withBestPattern = context.is12Hour
)
}
fun Date.getHourIn24Format(location: Location): String {
return getFormattedDate("H", location)
}
/**
* See CalendarHelper.supportedCalendars for full list of supported calendars
*/
fun Date.getFormattedMediumDayAndMonthInAdditionalCalendar(
location: Location? = null,
context: Context,
): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val calendarId = CalendarHelper.getAlternateCalendarSetting(context)
if (calendarId != null) {
val alternateCalendar = CalendarHelper.getCalendars(context).firstOrNull { it.id == calendarId }
if (alternateCalendar != null) {
val locale = context.currentLocale
val uLocale = ULocale.Builder().apply {
setLanguageTag(locale.toLanguageTag())
setUnicodeLocaleKeyword(CalendarHelper.CALENDAR_EXTENSION_TYPE, calendarId)
alternateCalendar.additionalParams?.forEach {
setUnicodeLocaleKeyword(it.key, it.value)
}
}.build()
SimpleDateFormat(
if (!alternateCalendar.specificPattern.isNullOrEmpty()) {
alternateCalendar.specificPattern
} else {
DateTimePatternGenerator.getInstance(uLocale).getBestPattern("d MMM")
},
uLocale
).apply {
timeZone = location?.timeZone?.let { TimeZone.getTimeZone(it.id) } ?: TimeZone.getDefault()
}.format(this)
} else {
null
}
} else {
null
}
} else {
null
}
}
fun Date.toCalendar(location: Location): Calendar {
return Calendar.getInstance().also {
it.time = this
it.timeZone = location.timeZone
}
}
/**
* Optimized function to get yyyy-MM-dd formatted date
* Takes 0 ms on my device compared to 2-3 ms for getFormattedDate() (which uses SimpleDateFormat)
* Saves about 1 second when looping through 24 hourly over a 16 day period
*/
fun Calendar.getIsoFormattedDate(): String {
return "${this[Calendar.YEAR]}-${getMonth(twoDigits = true)}-${getDayOfMonth(twoDigits = true)}"
}
fun Calendar.getMonth(twoDigits: Boolean = false): String {
return "${(this[Calendar.MONTH] + 1).let { month ->
if (twoDigits && month.toString().length < 2) "0$month" else month
}}"
}
fun Calendar.getDayOfMonth(twoDigits: Boolean = false): String {
return "${this[Calendar.DAY_OF_MONTH].let { day ->
if (twoDigits && day.toString().length < 2) "0$day" else day
}}"
}
fun Date.getIsoFormattedDate(location: Location): String {
return toCalendar(location).getIsoFormattedDate()
}
fun Date.getCalendarMonth(location: Location): Month {
return Month.fromCalendarMonth(toCalendarWithTimeZone(location.timeZone)[Calendar.MONTH])
}

View file

@ -0,0 +1,99 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* The functions below make use of old java.util.* that should be replaced with android.icu
* counterparts, introduced in Android SDK 24
*/
fun Date.toCalendarWithTimeZone(zone: TimeZone): Calendar {
return Calendar.getInstance().also {
it.time = this
it.timeZone = zone
}
}
/**
* Get a date at midnight on a specific timezone from a formatted date
* @this formattedDate in yyyy-MM-dd format
* @param timeZoneP
* @return Date
*/
fun String.toDateNoHour(timeZoneP: TimeZone = TimeZone.getDefault()): Date? {
if (isEmpty() || length < 10) return null
return Calendar.getInstance().also {
it.timeZone = timeZoneP
it.set(Calendar.YEAR, substring(0, 4).toInt())
it.set(Calendar.MONTH, substring(5, 7).toInt() - 1)
it.set(Calendar.DAY_OF_MONTH, substring(8, 10).toInt())
it.set(Calendar.HOUR_OF_DAY, 0)
it.set(Calendar.MINUTE, 0)
it.set(Calendar.SECOND, 0)
it.set(Calendar.MILLISECOND, 0)
}.time
}
@Deprecated("Makes no sense, must be replaced")
fun Date.toTimezone(timeZone: TimeZone = TimeZone.getDefault()): Date {
val calendarWithTimeZone = toCalendarWithTimeZone(timeZone)
return Date(
calendarWithTimeZone[Calendar.YEAR] - 1900,
calendarWithTimeZone[Calendar.MONTH],
calendarWithTimeZone[Calendar.DAY_OF_MONTH],
calendarWithTimeZone[Calendar.HOUR_OF_DAY],
calendarWithTimeZone[Calendar.MINUTE],
calendarWithTimeZone[Calendar.SECOND]
)
}
@Deprecated("Use toTimezoneSpecificHour instead")
fun Date.toTimezoneNoHour(timeZone: TimeZone = TimeZone.getDefault()): Date {
return toTimezoneSpecificHour(timeZone)
}
fun Date.toTimezoneSpecificHour(
timeZone: TimeZone = TimeZone.getDefault(),
specificHour: Int = 0,
): Date {
return toCalendarWithTimeZone(timeZone).apply {
set(Calendar.YEAR, get(Calendar.YEAR))
set(Calendar.MONTH, get(Calendar.MONTH))
set(Calendar.DAY_OF_MONTH, get(Calendar.DAY_OF_MONTH))
set(Calendar.HOUR_OF_DAY, specificHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.time
}
@Deprecated("Use ICU functions instead")
fun Date.getFormattedDate(
pattern: String,
timeZone: TimeZone?,
locale: Locale,
): String {
return SimpleDateFormat(pattern, locale).apply {
setTimeZone(timeZone ?: TimeZone.getDefault())
}.format(this)
}

View file

@ -0,0 +1,281 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.animation.Animator
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.os.Build
import android.provider.Settings
import android.provider.Settings.SettingNotFoundException
import android.util.TypedValue
import android.view.View
import android.view.Window
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import android.view.animation.OvershootInterpolator
import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
import androidx.annotation.Px
import androidx.annotation.Size
import androidx.annotation.StyleRes
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap
import androidx.core.view.WindowInsetsControllerCompat
import com.google.android.material.resources.TextAppearance
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.min
import kotlin.math.roundToInt
private const val MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_PHONE = 512
private const val MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_TABLET = 600
val FLOATING_DECELERATE_INTERPOLATOR: Interpolator = DecelerateInterpolator(1f)
const val DEFAULT_CARD_LIST_ITEM_ELEVATION_DP = 2f
private const val SQUISHED_BLOCK_FACTOR = 1.1f
val Context.isTabletDevice: Boolean
get() = (
resources.configuration.screenLayout
and Configuration.SCREENLAYOUT_SIZE_MASK
) >= Configuration.SCREENLAYOUT_SIZE_LARGE
val Context.isLandscape: Boolean
get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
/**
* Minimum size is adjusted from font scale:
* - At 1.0 font scale, minimum size for a block is 1.0 (160 dp)
* - At 2.0 font scale, minimum size for a block is 2.0 (320 dp)
*/
val Context.minBlockWidth: Float
get() = 0.5f + fontScale.div(2f)
/**
* @param widthInDp available width in which blocks will be displayed
* @return a number of blocks between 1 and 5 that can fit
*/
fun Context.getBlocksPerRow(
widthInDp: Float = windowWidth.toFloat().div(density),
): Int {
val potentialResult = floor(widthInDp.div(minBlockWidth)).roundToInt().coerceIn(1..5)
return if (potentialResult > 2) {
// if more than 2 blocks can fit, we prefer displaying less blocks and have a bit more room
// rather than having squished blocks
floor(widthInDp.div(minBlockWidth * SQUISHED_BLOCK_FACTOR)).roundToInt().coerceIn(1..5)
} else {
potentialResult
}
}
/**
* Simplified estimation by taking into account more than 2 blocks are never squished,
* and that devices with drawer layout always have space for at least 2 non-squished blocks
*/
val Context.areBlocksSquished: Boolean
get() = getBlocksPerRow().let { blocksPerRow ->
if (blocksPerRow > 2) {
false
} else {
windowWidth.toFloat().div(density).div(minBlockWidth * SQUISHED_BLOCK_FACTOR) < blocksPerRow
}
}
val Context.isRtl: Boolean
get() = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
val Context.isDarkMode: Boolean
get() = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val Context.isMotionReduced: Boolean
get() {
return try {
Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE) == 0f
} catch (e: SettingNotFoundException) {
false
}
}
val Context.density: Int
get() {
return resources.displayMetrics.densityDpi
}
val Context.fontScale: Float
get() {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resources.configuration.fontScale *
resources.displayMetrics.densityDpi.div(android.util.DisplayMetrics.DENSITY_DEVICE_STABLE.toFloat())
} else {
1f // Lets just ignore it on old Android versions
}
}
// Take into account font scale, but not as much
// For example a font scale of 1.6 makes the width 1.3 times larger
val Context.fontScaleToApply: Float
get() {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
fontScale.let {
if (it != 1f) 1f + abs(it - 1f).div(2f).times(if (it > 1f) 1f else -1f) else it
}
} else {
1f // Lets just ignore it on old Android versions
}
}
val Context.windowHeightInDp: Float
get() {
return pxToDp(resources.displayMetrics.heightPixels)
}
val Context.windowWidthInDp: Float
get() {
return pxToDp(resources.displayMetrics.widthPixels)
}
val Context.windowWidth: Int
@Px
get() {
return resources.displayMetrics.widthPixels
}
fun Context.dpToPx(dp: Float): Float {
return dp * (resources.displayMetrics.densityDpi / 160f)
}
fun Context.spToPx(sp: Int): Float {
return sp * TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1.0f, resources.displayMetrics)
}
@Suppress("unused")
fun Context.pxToDp(@Px px: Int): Float {
return px / (resources.displayMetrics.densityDpi / 160f)
}
@Px
fun Context.getTabletListAdaptiveWidth(@Px width: Int): Int {
return if (!isTabletDevice && !isLandscape) {
width
} else {
min(
width.toFloat(),
dpToPx(
if (isTabletDevice) {
MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_TABLET
} else {
MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_PHONE
}.toFloat()
)
).toInt()
}
}
@SuppressLint("RestrictedApi", "VisibleForTests")
fun Context.getTypefaceFromTextAppearance(
@StyleRes textAppearanceId: Int,
): Typeface {
return TextAppearance(this, textAppearanceId).getFont(this)
}
fun Context.getThemeColor(
@AttrRes id: Int,
): Int {
val typedValue = TypedValue()
theme.resolveAttribute(id, typedValue, true)
return typedValue.data
}
fun Context.getColorResource(@ColorRes id: Int): androidx.compose.ui.graphics.Color {
return androidx.compose.ui.graphics.Color(ResourcesCompat.getColor(resources, id, theme))
}
@Suppress("DEPRECATION")
fun Window.setSystemBarStyle(
lightStatus: Boolean,
) {
var newLightStatus = lightStatus
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// Use default dark and light platform colors from EdgeToEdge
val colorSystemBarDark = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
val colorSystemBarLight = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// Always apply a dark shader as a light or transparent status bar is not supported
newLightStatus = false
}
statusBarColor = Color.TRANSPARENT
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && lightStatus) {
colorSystemBarLight
} else {
colorSystemBarDark
}
} else {
isStatusBarContrastEnforced = false
isNavigationBarContrastEnforced = true
}
// Contrary to the documentation FALSE applies a light foreground color and TRUE a dark foreground color
WindowInsetsControllerCompat(this, decorView).run {
isAppearanceLightStatusBars = newLightStatus
isAppearanceLightNavigationBars = lightStatus
}
}
fun Drawable.toBitmap(): Bitmap {
val bitmap = createBitmap(intrinsicWidth, intrinsicHeight)
val canvas = Canvas(bitmap)
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
draw(canvas)
return bitmap
}
// translationY, scaleX, scaleY
@Size(3)
fun View.getFloatingOvershotEnterAnimators(): Array<Animator> {
return getFloatingOvershotEnterAnimators(1.5f)
}
@Size(3)
fun View.getFloatingOvershotEnterAnimators(overshootFactor: Float): Array<Animator> {
return getFloatingOvershotEnterAnimators(overshootFactor, translationY, scaleX, scaleY)
}
@Size(3)
fun View.getFloatingOvershotEnterAnimators(
overshootFactor: Float,
translationYFrom: Float,
scaleXFrom: Float,
scaleYFrom: Float,
): Array<Animator> {
val translation: Animator = ObjectAnimator.ofFloat(this, "translationY", translationYFrom, 0f)
translation.interpolator = OvershootInterpolator(overshootFactor)
val scaleX: Animator = ObjectAnimator.ofFloat(this, "scaleX", scaleXFrom, 1f)
scaleX.interpolator = FLOATING_DECELERATE_INTERPOLATOR
val scaleY: Animator = ObjectAnimator.ofFloat(this, "scaleY", scaleYFrom, 1f)
scaleY.interpolator = FLOATING_DECELERATE_INTERPOLATOR
return arrayOf(translation, scaleX, scaleY)
}

View file

@ -0,0 +1,43 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import org.breezyweather.BuildConfig
import java.io.File
/**
* Returns the uri of a file
*
* @param context context of application
*/
fun File.getUriCompat(context: Context): Uri {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", this)
} else {
toUri()
}
}
fun fileFromAsset(resource: Int, context: Context): File =
File("${context.cacheDir}/$resource").apply {
writeBytes(context.resources.openRawResource(resource).readBytes())
}

View file

@ -0,0 +1,135 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.appcompat.app.AppCompatDelegate
import java.util.Locale
val Context.currentLocale: Locale
get() {
return AppCompatDelegate.getApplicationLocales().get(0)
?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resources.configuration.locales[0]
} else {
@Suppress("DEPRECATION")
resources.configuration.locale
}
}
// TODO: Review this use vs toLanguageTag()
val Locale.code: String
get() {
val language = language
val country = country
return if (isTraditionalChinese) {
language.lowercase() + "-" + country.lowercase()
} else {
language.lowercase()
}
}
// TODO: Review this use vs toLanguageTag()
val Locale.codeWithCountry: String
get() {
val language = language
val country = country
return if (!country.isNullOrEmpty()) {
language.lowercase() + "-" + country.lowercase()
} else {
language.lowercase()
}
}
// Accepts "Hant" for traditional chinese but no country code otherwise
val Locale.codeForGeonames: String
get() {
return if (isTraditionalChinese) {
language.lowercase() + "-Hant"
} else {
language.lowercase()
}
}
// Everything in uppercase + "ZHT" for traditional Chinese
val Locale.codeForNaturalEarth: String
get() {
return if (isTraditionalChinese) {
language.uppercase() + "T"
} else {
language.uppercase()
}
}
val Locale.isChinese: Boolean
get() = language.equals("zh", ignoreCase = true)
// There is no way to access the script used, so assume Taiwan, Hong Kong and Macao
val Locale.isTraditionalChinese: Boolean
get() = isChinese &&
arrayOf("TW", "HK", "MO").any { country.equals(it, ignoreCase = true) }
fun Locale.getCountryName(countryCode: String): String {
return Locale.Builder()
.setLanguage(language)
.setRegion(countryCode)
.build()
.displayCountry
}
/**
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = ""): String {
return if (length > count) {
take(count - replacement.length) + replacement
} else {
this
}
}
fun String.capitalize(locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build()): String {
return replaceFirstChar { firstChar ->
if (firstChar.isLowerCase()) {
firstChar.titlecase(locale)
} else {
firstChar.toString()
}
}
}
fun String.uncapitalize(locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build()): String {
return replaceFirstChar { firstChar ->
if (firstChar.isUpperCase()) {
firstChar.lowercase(locale)
} else {
firstChar.toString()
}
}
}
fun Context.getStringByLocale(
id: Int,
locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build(),
): String {
val configuration = Configuration(resources.configuration)
configuration.setLocale(locale)
return createConfigurationContext(configuration).resources.getString(id)
}

View file

@ -0,0 +1,33 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import androidx.compose.ui.Modifier
/**
* Source: ProAndroidDev
* https://proandroiddev.com/jetpack-compose-tricks-conditionally-applying-modifiers-for-dynamic-uis-e3fe5a119f45
*/
inline fun Modifier.conditional(
condition: Boolean,
ifTrue: Modifier.() -> Modifier,
ifFalse: Modifier.() -> Modifier = { this },
): Modifier = if (condition) {
then(ifTrue(Modifier))
} else {
then(ifFalse(Modifier))
}

View file

@ -0,0 +1,49 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.core.content.getSystemService
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/c5e8c9f01fa6b54425675ee3ebdc6f735aee7ba9/app/src/main/java/eu/kanade/tachiyomi/util/system/NetworkExtensions.kt
*/
val Context.connectivityManager: ConnectivityManager
get() = getSystemService()!!
fun Context.isOnline(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
val maxTransport = NetworkCapabilities.TRANSPORT_LOWPAN
return if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
// If VPN is enabled, but there is no other transport enabled, we are actually offline
(NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).count(networkCapabilities::hasTransport) > 1
} else {
(NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport)
}
} else {
@Suppress("DEPRECATION")
return connectivityManager.activeNetworkInfo?.isConnected ?: false
}
}

View file

@ -0,0 +1,118 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.Manifest
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationChannelGroupCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/953f5fb0253879547a94f88231b36ce81a35b48e/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt
*/
val Context.notificationManager: NotificationManager
get() = getSystemService()!!
fun Context.notify(
id: Int,
channelId: String,
block: (NotificationCompat.Builder.() -> Unit)? = null,
) {
val notification = notificationBuilder(channelId, block).build()
notify(id, notification)
}
fun Context.notify(id: Int, notification: Notification) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
NotificationManagerCompat.from(this).notify(id, notification)
}
fun Context.cancelNotification(id: Int) {
NotificationManagerCompat.from(this).cancel(id)
}
/**
* Helper method to create a notification builder.
*
* @param channelId the channel id.
* @param block the function that will execute inside the builder.
* @return a notification to be displayed or updated.
*/
fun Context.notificationBuilder(
channelId: String,
block: (NotificationCompat.Builder.() -> Unit)? = null,
): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(this, channelId)
if (block != null) {
builder.block()
}
return builder
}
/**
* Helper method to build a notification channel group.
*
* @param channelId the channel id.
* @param block the function that will execute inside the builder.
* @return a notification channel group to be displayed or updated.
*/
fun buildNotificationChannelGroup(
channelId: String,
block: (NotificationChannelGroupCompat.Builder.() -> Unit),
): NotificationChannelGroupCompat {
val builder = NotificationChannelGroupCompat.Builder(channelId)
builder.block()
return builder.build()
}
/**
* Helper method to build a notification channel.
*
* @param channelId the channel id.
* @param channelImportance the channel importance.
* @param block the function that will execute inside the builder.
* @return a notification channel to be displayed or updated.
*/
fun buildNotificationChannel(
channelId: String,
channelImportance: Int,
block: (NotificationChannelCompat.Builder.() -> Unit),
): NotificationChannelCompat {
val builder = NotificationChannelCompat.Builder(channelId, channelImportance)
builder.block()
return builder.build()
}

View file

@ -0,0 +1,97 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalLayoutDirection
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.ceil
import kotlin.math.floor
operator fun Int?.plus(other: Int?): Int? = if (this != null || other != null) {
(this ?: 0) + (other ?: 0)
} else {
null
}
operator fun Double?.plus(other: Double?): Double? = if (this != null || other != null) {
(this ?: 0.0) + (other ?: 0.0)
} else {
null
}
operator fun Double?.minus(other: Double?): Double? = if (this != null || other != null) {
(this ?: 0.0) - (other ?: 0.0)
} else {
null
}
fun Double.ensurePositive(): Double? = if (this >= 0.0) this else null
fun Double.roundUpToNearestMultiplier(multiplier: Double): Double {
return ceil(div(multiplier)).times(multiplier)
}
fun Double.roundDownToNearestMultiplier(multiplier: Double): Double {
return floor(div(multiplier)).times(multiplier)
}
fun Double.roundDecimals(decimals: Int): Double? {
return if (!isNaN()) {
BigDecimal(this).setScale(decimals, RoundingMode.HALF_UP).toDouble()
} else {
null
}
}
val Array<Double>.median: Double?
get() {
if (isEmpty()) return null
sort()
return if (size % 2 != 0) {
this[size / 2]
} else {
(this[(size - 1) / 2] + this[size / 2]) / 2.0
}
}
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/58a0add4f6bd8a5ab1006755035ff1b102355d4a/presentation-core/src/main/java/tachiyomi/presentation/core/util/PaddingValues.kt
*/
@Composable
@ReadOnlyComposable
operator fun PaddingValues.plus(other: PaddingValues): PaddingValues {
val layoutDirection = LocalLayoutDirection.current
return PaddingValues(
start = calculateStartPadding(layoutDirection) +
other.calculateStartPadding(layoutDirection),
end = calculateEndPadding(layoutDirection) +
other.calculateEndPadding(layoutDirection),
top = calculateTopPadding() + other.calculateTopPadding(),
bottom = calculateBottomPadding() + other.calculateBottomPadding()
)
}

View file

@ -0,0 +1,32 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
/**
* Allow to split a string while keeping delimiters
*/
fun String.splitKeeping(str: String): List<String> {
return split(str).flatMap { listOf(it, str) }.dropLast(1).filterNot { it.isEmpty() }
}
fun String.splitKeeping(vararg strs: String): List<String> {
var res = listOf(this)
strs.forEach { str ->
res = res.flatMap { it.splitKeeping(str) }
}
return res
}

View file

@ -0,0 +1,434 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.content.Context
import android.graphics.Color
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import org.breezyweather.R
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.unit.distance.Distance
import org.breezyweather.unit.distance.Distance.Companion.meters
import org.breezyweather.unit.duration.format
import org.breezyweather.unit.formatting.UnitWidth
import org.breezyweather.unit.pollen.PollenConcentrationUnit
import org.breezyweather.unit.pollutant.PollutantConcentrationUnit
import org.breezyweather.unit.precipitation.Precipitation
import org.breezyweather.unit.precipitation.PrecipitationUnit
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.ratio.Ratio
import org.breezyweather.unit.ratio.RatioUnit
import org.breezyweather.unit.speed.Speed
import org.breezyweather.unit.temperature.Temperature
import org.breezyweather.unit.temperature.TemperatureUnit
import kotlin.time.Duration
import kotlin.time.DurationUnit
/**
* TODO: Lot of duplicates code in this page
* Technically, we can do a <T : WeatherValue> extension, but we need to handle how we are getting the user-preferred
* unit
*/
/**
* Convenient format function with parameters filled for our app
* Getting the default unit from Settings is terribly slow, so it must be send as parameter
*/
fun Temperature.formatMeasure(
context: Context,
unit: TemperatureUnit,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
showSign: Boolean = false,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = unit,
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
showSign = showSign,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
* Getting the default unit from Settings is terribly slow, so it must be send as parameter
*/
fun Temperature.formatValue(
context: Context,
unit: TemperatureUnit,
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = unit,
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Source: https://weather.metoffice.gov.uk/guides/what-does-this-forecast-mean
*/
const val VISIBILITY_VERY_POOR = 1000.0
const val VISIBILITY_POOR = 4000.0
const val VISIBILITY_MODERATE = 10000.0
const val VISIBILITY_GOOD = 20000.0
const val VISIBILITY_CLEAR = 40000.0
val visibilityScaleThresholds = listOf(
0.meters,
VISIBILITY_VERY_POOR.meters,
VISIBILITY_POOR.meters,
VISIBILITY_MODERATE.meters,
VISIBILITY_GOOD.meters,
VISIBILITY_CLEAR.meters
)
/**
* @param context
*/
fun Distance.getVisibilityDescription(context: Context): String? {
return when (inMeters) {
in 0.0..<VISIBILITY_VERY_POOR -> context.getString(R.string.visibility_very_poor)
in VISIBILITY_VERY_POOR..<VISIBILITY_POOR -> context.getString(R.string.visibility_poor)
in VISIBILITY_POOR..<VISIBILITY_MODERATE -> context.getString(R.string.visibility_moderate)
in VISIBILITY_MODERATE..<VISIBILITY_GOOD -> context.getString(R.string.visibility_good)
in VISIBILITY_GOOD..<VISIBILITY_CLEAR -> context.getString(R.string.visibility_clear)
in VISIBILITY_CLEAR..Double.MAX_VALUE -> context.getString(R.string.visibility_perfectly_clear)
else -> null
}
}
/**
* Convenient format function with parameters filled for our app
*/
fun Distance.formatMeasure(
context: Context,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = settings.getDistanceUnit(context),
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Distance.formatValue(
context: Context,
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = settings.getDistanceUnit(context),
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
fun Speed.getBeaufortScaleStrength(context: Context): String? {
return context.resources.getStringArray(R.array.wind_strength_descriptions).getOrElse(inBeaufort) { null }
}
@ColorInt
fun Speed.getBeaufortScaleColor(context: Context): Int {
return context.resources.getIntArray(R.array.wind_strength_colors).getOrNull(inBeaufort) ?: Color.TRANSPARENT
}
/**
* Convenient format function with parameters filled for our app
*/
fun Speed.formatMeasure(
context: Context,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = settings.getSpeedUnit(context),
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Speed.formatValue(
context: Context,
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = settings.getSpeedUnit(context),
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Precipitation.formatMeasure(
context: Context,
unit: PrecipitationUnit = SettingsManager.getInstance(context).getPrecipitationUnit(context),
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = unit,
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Precipitation.formatValue(
context: Context,
unit: PrecipitationUnit = SettingsManager.getInstance(context).getPrecipitationUnit(context),
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = unit,
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Precipitation.formatMeasureIntensity(
context: Context,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatIntensity(
context = context,
unit = settings.getPrecipitationUnit(context),
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Pressure.formatMeasure(
context: Context,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = settings.getPressureUnit(context),
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Pressure.formatValue(
context: Context,
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = settings.getPressureUnit(context),
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun PollutantConcentrationUnit.formatMeasure(
context: Context,
value: Number,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
value = value,
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun PollenConcentrationUnit.formatMeasure(
context: Context,
value: Number,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
value = value,
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Duration.formatTime(
context: Context,
smallestUnit: DurationUnit = DurationUnit.HOURS,
valueWidth: UnitWidth = UnitWidth.SHORT,
unitWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = DurationUnit.HOURS,
smallestUnit = smallestUnit,
valueWidth = valueWidth,
unitWidth = unitWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Ratio.formatPercent(
context: Context,
valueWidth: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return format(
context = context,
unit = RatioUnit.PERCENT,
valueWidth = valueWidth,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
/**
* Convenient format function with parameters filled for our app
*/
fun Ratio.formatValue(
context: Context,
width: UnitWidth = UnitWidth.SHORT,
): String {
val settings = SettingsManager.getInstance(context)
return formatValue(
unit = RatioUnit.PERCENT,
width = width,
locale = context.currentLocale,
useNumberFormatter = settings.useNumberFormatter,
useMeasureFormat = settings.useMeasureFormat
)
}
// We don't need any cloud cover unit, it's just a percent, but we need some helpers
/**
* Source: WMO Cloud distribution for aviation
*/
const val CLOUD_COVER_SKC = 12.5 // 1 okta
const val CLOUD_COVER_FEW = 37.5 // 3 okta
const val CLOUD_COVER_SCT = 62.5 // 5 okta
const val CLOUD_COVER_BKN = 87.5 // 7 okta
const val CLOUD_COVER_OVC = 100.0 // 8 okta
fun Ratio.getCloudCoverColor(context: Context): Int {
return when (inPercent) {
in 0.0..<CLOUD_COVER_FEW -> ContextCompat.getColor(context, R.color.colorLevel_1)
in CLOUD_COVER_FEW..CLOUD_COVER_SCT -> ContextCompat.getColor(context, R.color.colorLevel_2)
in CLOUD_COVER_SCT..100.0 -> ContextCompat.getColor(context, R.color.colorLevel_3)
else -> Color.TRANSPARENT
}
}
/**
* @param context
*/
fun Ratio.getCloudCoverDescription(context: Context): String? {
return when (inPercent) {
in 0.0..<CLOUD_COVER_SKC -> context.getString(R.string.common_weather_text_clear_sky)
in CLOUD_COVER_SKC..<CLOUD_COVER_FEW -> context.getString(R.string.common_weather_text_mostly_clear)
in CLOUD_COVER_FEW..<CLOUD_COVER_SCT -> context.getString(R.string.common_weather_text_partly_cloudy)
in CLOUD_COVER_SCT..<CLOUD_COVER_BKN -> context.getString(R.string.common_weather_text_mostly_cloudy)
in CLOUD_COVER_BKN..CLOUD_COVER_OVC -> context.getString(R.string.common_weather_text_cloudy)
else -> null
}
}

View file

@ -0,0 +1,61 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
/**
* Source: Android Developers, Chris Banes
* https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1
*/
/**
* Apply window insets (system bars and display cutouts) for a view.
*/
fun View.doOnApplyWindowInsets(f: (View, Insets) -> Unit) {
// Set an actual OnApplyWindowInsetsListener which proxies to the given lambda
setOnApplyWindowInsetsListener(this) { v, insets ->
val i = insets.getInsets(
WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout()
)
f(v, i)
// Always return the insets, so that children can also use them
insets
}
// request some insets
requestApplyInsetsWhenAttached()
}
fun View.requestApplyInsetsWhenAttached() {
if (isAttachedToWindow) {
// We're already attached, just request as normal
requestApplyInsets()
} else {
// We're not attached to the hierarchy, add a listener to request when we are
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
v.requestApplyInsets()
}
override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}

View file

@ -0,0 +1,56 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.common.extensions
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.delay
import org.breezyweather.common.utils.helpers.LogHelper
/**
* Taken from Mihon
* Apache License, Version 2.0
*
* https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt
*/
val Context.workManager: WorkManager
get() = WorkManager.getInstance(this)
/**
* Makes this worker run in the context of a foreground service.
*
* Note that this function is a no-op if the process is subject to foreground
* service restrictions.
*
* Moving to foreground service context requires the worker to run a bit longer,
* allowing Service.startForeground() to be called and avoiding system crash.
*/
suspend fun CoroutineWorker.setForegroundSafely() {
try {
setForeground(getForegroundInfo())
delay(500)
} catch (e: IllegalStateException) {
LogHelper.log(msg = "Not allowed to set foreground job")
}
}
fun WorkManager.isRunning(tag: String): Boolean {
val list = getWorkInfosByTag(tag).get()
return list.any { it.state == WorkInfo.State.RUNNING }
}

Some files were not shown because too many files have changed in this diff Show more