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

31
.editorconfig Normal file
View file

@ -0,0 +1,31 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{xml,sq,sqm}]
indent_size = 4
# noinspection EditorConfigKeyCorrectness
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = false
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ktlint_code_style = intellij_idea
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_class-signature = disabled
ktlint_standard_comment-wrapping = disabled
ktlint_standard_discouraged-comment-location = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_type-argument-comment = disabled
ktlint_standard_type-parameter-comment = disabled

BIN
.idea/icon.png generated Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

297
CHANGELOG.md Normal file
View file

@ -0,0 +1,297 @@
# Old changelogs
- [Changelog for v5.x](docs/CHANGELOG_5.x.md)
- [Changelog for v4.x](docs/CHANGELOG_4.x.md)
# Version 6.1.x (not yet released)
The following features are already available in the current branch, but will be removed before each v6.0.x release and restored after, during the testing phase.
**New features**
- Content provider: allows (with your permission) other apps to query your weather data. [Read the announcement](https://github.com/breezy-weather/breezy-weather/discussions/2089)
- New broadcast: you can use `org.breezyweather.ACTION_UPDATE_NOTIFIER` (or `org.breezyweather.debug.ACTION_UPDATE_NOTIFIER` with the debug build) to be notified of updated locations (most common use case is coupled with the content provider)
# Version 6.0.12 (not yet released)
**Improvements and fixes**
- Daily/hourly forecast - Ensure the maximum value is always at a minimum defined value to ensure data is put in perspective, and remove threshold lines that werent very useful and cluttering the interface (wind, precipitation, cloud cover)
- Make 24-hour charts and nowcasting charts less prone to swipe to next screen
- Main screen - Fix moon icon disappearing past midnight
- Main screen - Fix blocks not appearing after fade in animation was interrupted due to fast scrolling
- Main screen - Fix animations re-appearing when scrolling (@min7-i)
- Fix current air quality disappearing when refreshing too fast
**Weather sources**
- China - Fix refresh error for some users (@kmod-midori)
- MET Éireann - Migrate to new API
- Nominatim - Add missing preference to change server instance
- Open-Meteo - Allow individual selection of new weather models: ECMWF IFS HRES 9 km, NCEP NAM U.S. Conus, MeteoSwiss
- OpenWeather - Fix current condition not translated
- Pirate Weather - Add support for thunderstorm icon (@cloneofghosts)
- Pollen Information AT - Add support as a pollen source for some European countries (@phileix)
**Translations**
- Translations updated
# Version 6.0.11-rc (2025-09-03)
**Improvements and fixes**
- Fix crash when entering Appearance settings using 12-hour format with scheduled dark mode
- Current location - Fix details sometimes not saved to database (previous location details restored on restart of the app)
- Remove animations in the pressure block as it caused flickering
- Change default distance unit for Germany to kilometer, as per DWD usage
- Change default speed unit for Netherlands to meter per second, as per KNMI usage
- Fix threshold value for scattered cloud cover (@cloneofghosts)
**Translations**
- Translations updated
# Version 6.0.10-rc (2025-09-01)
**Improvements and fixes**
- Add instructions to pull to refresh instead of leaving a blank screen when weather failed to load initially (@Amitesh-exp)
**Weather sources**
- ECCC - Technical changes
- NCDR - Fix error when there is no alert (@chunshek)
**Translations**
- Translations updated
# Version 6.0.9-beta (2025-08-31)
**Improvements and fixes**
- Clarify which dark mode is currently used at system level in Appearance settings, which may help Xiaomi device owners detect a potential bug in the MIUI dark mode implementation
- Freenet - Improve wording of messages about non-free network services
- Freenet - Display the names of non-free network services in source lists to let the user know about the availability of other sources in the Standard flavor
- Android 11+ - Fix unneeded zeros sometimes showing in fractions
**Weather sources**
- IP.SB / Baidu IP Location - Dont require Android location to be on
**Translations**
- Translations updated
**Technical**
- Fallback to latest known current data rather than current hour forecast when last successful refresh was less than 30 min ago
# Version 6.0.8-beta (2025-08-27)
**Improvements and fixes**
- Minor changes to weather blocks to improve accessibility (text size, color contrast, etc.)
- Widgets - Round temperature values
- Nowcasting block - Fix truncated start and end values
**Translations**
- Translations updated
# Version 6.0.7-beta (2025-08-26)
**Translations**
- Translations updated
- Add missing distance, speed and precipitation unit translations on Android < 7
**Technical**
- Added timezone deduction based on subdivision codes (@chunshek)
# Version 6.0.6-alpha (2025-08-24)
**Improvements and fixes**
- Fix crash on startup on Android 5.0, 5.1 and 6.0
- Fix crash on Android 7.0/7.1 when formatting some units
- Widgets - Fix crash on Android 9.0 to 11.0 with font size set to something other than 100%
**Weather sources**
- [HERE] Removed following recent restrictions on free API
**Translations**
- Translations updated
# Version 6.0.5-alpha (2025-08-23)
This version is still an experimental one, with a significant rewrite of the refresh process core, especially on current locations. Weather data for all locations will be reset due to a major technical change in the database. A simple refresh will bring it back.
**Removed features**
- Mean daytime/nighttime temperatures as threshold lines. Use a normals source instead
- [Met Office UK] Removed address lookup feature
- Pressure unit - Kilogram force per square centimeter
**Improvements and fixes**
- Main screen - Allow to move small blocks by drag & drop
- Main screen - The number of items displayed at once in daily/hourly forecast now depends on display size and font scale (previously always 5 in portrait, and 7 in landscape)
- Main screen - Show “Negligible” inside Pollen block if there is no pollen today instead of an empty block
- Main screen - Allow up to 5 blocks on a row depending on width display size and font scale
- Main screen - Move refresh time out of app bar when scrolling
- Main screen - Fix settings not applying immediately
- Main screen - Fix shooting stars getting stuck in the corner in landscape
- Details - Add a bottom margin at the end of each page, so that it doesnt overlap with the floating button
- Details - Dont animate charts when “Other element animations” is disabled
- Details - Air quality - Add individual charts for each pollutant
- Details - Humidity/Dewpoint/Cloud cover - Show min/max of the day
- Details - Pressure/Visibility - Fix sometimes wrong daily value
- Details - Fallback to current value on Today screen when daily value is missing
- Details - Add visibility and cloud cover scales
- Details - Fix top X-axis sometimes showing “-” for some sources
- Details - Charts are now slightly wider following the removal of start and end paddings by removing midnight labels
- Alerts - Add “Translate” and “Share” to text select actions
- Nowcasting chart/Precipitation notification - Fix slightly wrong ending time of precipitation report
- Settings - Improve the location-based dark mode preference to make it easier to understand
- Sources - Add a “Recommended” section to the Source selection screen
- Refresh - Fix a rare crash when Android fails to send us the current location
- Refresh - Add an error when air quality forecast times dont match hourly forecast times (observed in India, for example)
- Refresh - Ensure range of (almost) all values provided by sources, so you no longer have to freak out when seeing -999° with PirateWeather or 1015° with Meteo AM
- Data sharing - Fix crash when sending too many locations (will now retry with less locations)
- Widgets - Improve UX of custom subtitle documentation (@codewithdipesh)
- Widgets - Improve line height on many widgets
- Widgets - Weekly - Spread day/night temperatures on 2 lines if necessary
- Widgets - Minor fixes
- Wallpaper - Due to some people running outdated versions of Breezy Weather just to see some gimmicks on their wallpaper, we bring back wallpaper animations behind a dangerous disabled-by-default option. We STRONGLY advise against enabling them.
**Weather sources**
- [AccuWeather] Restrict pollen to USA, Canada and Europe as its only available there (@chunshek)
- [China] Fix reversed color and severity for alerts (@chunshek)
- [EKUK] Fix failure to refresh air quality
- [FOSS Public Alert Server] Add support for this experimental source for alerts (@chunshek)
- [GeoSphere AT] Fix missing info in warnings
- [GeoSphere AT] Use the newer better endpoint for air quality
- [JMA] Added Thai translations (@chunshek)
- [LVGMC] Fix current observations (@chunshek)
- [NCDR] Added as alert source for Taiwan (@chunshek)
- [NCEI] Added support for normals (@chunshek)
- [Nominatim] Added as another location search
- [NSLC] Added as address lookup source for Taiwan (@chunshek)
- [NWS] Alerts - Updated terminology for Extreme Heat (@chunshek)
- [Open-Meteo] Restrict pollen to Europe as its only available there (@chunshek)
- [Pirate Weather] Add support for daily/hourly summaries
- [Veðurstofa Íslands] Added as forecast, current, alert and address lookup source for Iceland (@chunshek)
- [WMO SWIC] Avoid missing alerts which expired date was updated
- [ANAM-BF, DCCMS, DMN, DWR, EMI, GMet, IGEBU, INM, Mali-Météo, Météo Benin, Météo Tchad, Météo Togo, Mettelsat, MSD, Pirate Weather, SMA (Seychelles), SMA (Sudan), SSMS] Add to ̀freenet` flavor (was missing despite being FOSS)
**Translations**
- Initial translation added for Íslenska (@chunshek)
- Translations updated
- Alternate calendar: add Hebrew calendar
- Alternate calendar: add more defaults based on regional preferences
**Technical**
- Current location process refactoring: coordinates, forced refresh when coordinates changed from more than 5 km
- Address lookup process refactoring to prepare for future ability to add a location manually by coordinates
- Experimental offline timezone deduction for address lookup sources missing the info or for Nominatim search service (@chunshek)
- Unit conversion/formatting refactoring. **Known temporary issue:** Some distance, speed and precipitation units are no longer translated on Android < 7
# Version 6.0.4-alpha (2025-07-23)
**Improvements and fixes**
- Main screen - Improvements to some cut off texts with different display sizes
- Main screen - Improve the “two blocks per row” threshold when using custom font scale
- Details - Fix precipitation probability details being expressed in precipitation unit instead of %
- Fix missing normals every other refresh
**Translations**
- Translations updated
# Version 6.0.3-alpha (2025-07-22)
**New features**
- Redesign of main screen in Material 3 Expressive
- New information previously not shown on main screen: current wind gusts, clock (block not enabled by default)
- Redesign background animations/colors to better adapt to the selected dark mode and avoid saturated colors with bad contrast
**Removed features**
- Main screen - Details in header
- Main screen - Details block
- Custom weather and time per location
- Details of each different “feels like”. Will now just display the source-preferred feels like value, or if not available, our own computed feels like
**Improvements and fixes**
- Fix nowcasting chart not honoring precipitation unit override
- Details - Fix feels like toggle not remembered through days
- Main screen - Fix tapping daily/hourly feels like forecast opening conditions with feels like toggle off
- Details - Display normals as deviation directly under daytime/nighttime temperature
- Improve display of precipitation details
- Details - Make tooltips persistent until you click outside the bounds of the tooltip
- Details - Show current air quality on Today page when no daily air quality is available
**Translations**
- Translations updated
# Version 6.0.2-alpha (2025-07-19)
**Improvements and fixes**
- Fix crash in some cases on old Android devices
- Fix notification icons not showing
- Make main screen top icons feel more intuitive
**Weather sources**
- [Météo-France] Better formatting for warnings
# Version 6.0.1-alpha (2025-07-17)
**New features**
- Twilight dates (dawn and dusk)
**Removed features**
- Sun & Moon data from sources. Will now always be computed by Breezy Weather for consistency
**Improvements and fixes**
- Details page - Fix floating action button not updating in real time (@min7-i)
- Details page - Charts - Fix area fill in Right to Left languages (@chunshek)
- Details page - Fix jumping of the chart when tapping on it
- Details page - Workaround missing top padding in the FAB menu for small device heights (@min7-i)
- Details page - Conditions - move long weather condition description to a dedicated Daily summary card (especially noticeable with AccuWeather source)
- Details page - Sun & Moon - Fix glitched charts (@chunshek)
- Main screen - Attempt to make horizontal swipes in daily/hourly trends less prone to switch to prev/next locations
- Main screen - Move “Settings” icon to location list to be able to display icons on main screen without a submenu.
- Main screen - Better animation for main screen current temperature when using Fahrenheit or Kelvin
- Main screen - Use Material 3 Expressive buttons for forecast buttons
- Main screen - Fix sun & moon direction in RtL languages
- Main screen - Fix air quality direction in RtL languages
- Main screen - Fix missing hourly visibility in some cases
- Settings - Material 3 Expressive theme
- Settings - Add shortcuts to daily/trend configuration from cards configuration
- Fix tint of “Open in another app” icon in landscape mode
- Improve the formatting of today/tomorrow notification
- Live wallpaper - Fix wallpaper animating when switching between apps
- Fix specific language for the app not remembered after reboot
- UV - Better computing of missing hourly UV from day UV (@chunshek)
**Translations**
- Translations updated
- Default units are now based on system region. It does not support Android 16 “Measurement system” preference yet, as there seems to be no way to access this value for now.
- Better number formatting on Android >= 7
- Better measure formatting on Android >= 7
# Version 6.0.0-alpha (2025-06-26)
**New features**
- Complete overhaul of the daily details page to offer a better visualization of the data, and more explanations about the different types of weather data
- Past hourly forecast can now be viewed in the details page
**Removed features**
- Main screen hourly forecast card will now only show the next 24 hours, as the rest of the forecast can now be seen with more readability in the daily details page.
- The dedicated pollen page accessed when tapping on the pollen card now no longer exists, and was replaced by the pollen page in daily details.
- Tapping on the main screen air quality card no longer show more details, but open the air quality page in daily details instead.
- Tapping on an hourly item in the main screen hourly forecast no longer opens a dialog, but now opens the day details page of the currently selected type of data
**Improvements and fixes**
- Redesigned main screen footer to support links to the sources, a link to the privacy policy, and icons for the sources for which it is mandatory
- Fix crash when using “Open in another app” when no app on the phone is able to open it
**Weather sources**
- [ECCC] Added UV index
**Translations**
- Translations updated

128
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
github.com/papjul.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

171
CONTRIBUTE.md Normal file
View file

@ -0,0 +1,171 @@
# Contributions
## Rules for contributions
While we welcome pull requests, before implementing any new feature/improvement, we ask you to come talk to us, to be sure it goes in the right direction. We dont want you to spend time implementing something we dont want (see “Rules for new features/improvements requests” section below) or implementing it the wrong way.
You can also contribute to [existing issues tagged “Open to contributions”](https://github.com/breezy-weather/breezy-weather/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Open%20to%20contributions%22), or [existing ideas tagged “Open to contributions”](https://github.com/breezy-weather/breezy-weather/discussions?discussions_q=is%3Aopen+label%3A%22Open+to+contributions%22).
Prerequisites for pull requests contributions:
1. You are contributing to an issue/idea tagged “Open to contributions”, or an [org member](https://github.com/orgs/breezy-weather/people) gave you permission to work on it.
## Rules for new features/improvements requests
### General direction
Breezy Weather wants to be:
- a general weather app covering most of what you can expect from a weather app, but not *all* of what you can expect. For advanced usage, some specialized apps will always cover it better
- usable without having to be an expert to find anything in the app
- mainly target small displays, so we dont want to fit too many things, as we also want to let the design breathe a bit
### New features
Probably, the most requested thing. “If you dont want to make that feature for everyone, you can still make it a preference”.
Currently, we already have more than 50 preferences (not even counting widgets preferences and sources preferences!), which already provides a lot of customizability.
I know what youre going to tell me “If there are already that many options, just ONE additional option wont hurt”, but truth is if I had on top of existing preferences implemented every ONE preference people requested since this project began, I would have doubled the number of preferences (and Im only writing this 1.5 months after the project began), and things people are mostly looking for would be hard to find in the myriad of options.
At the same time, with the existing preferences, some people cant even find things, we already spend a lot of time helping people to find what they are looking for, and it shouldnt be that way. Some people may even just drop the app because it's too hard to use. This is really not something we want.
Additionally, any added preferences means implementing it, make the code execute conditionally for everyone, and maintain it (test it, handle bug reports, etc). What looks like a simple option can represent a lot of work.
So, the idea is to make a fair use of preferences, so if it covers too narrow of a case, it wont be implemented.
You can read [Niagaras design principles](https://help.niagaralauncher.app/article/8-niagaras-design-principles) for a similar take on the matter (although due to the nature of this weather app, the “universal” criteria doesnt always apply to us).
### New weather sources
To be candidate for inclusion in the project, a weather source must not require private information such as credit card or phone number to have a free key.
To be accepted as a main source, a source must have hourly forecast. A source can be implemented as a secondary-only source if they dont have hourly data but other secondary features.
Only features behind a free-tier will be accepted inside the project, so that any contributor can keep maintaining it in the long term.
Additionally, we usually dont accept sources that are just frontends to other sources (for example, if they use AccuWeather data, we will just use AccuWeather directly).
Examples of weather sources that dont fit:
- Apple WeatherKit (no free-tier)
- Microsoft Azure (free-tier requires credit card info)
- Weatherbit (free-tier only has “current” feature, with only 50 requests per day, so its not worth the maintenance cost)
Note that some national sources dont have endpoints by coordinates, or reverse geocoding (find nearest city/station), so we cant support them.
## Git setup for pull requests
### Init
Fork the project on GitHub.
Clone the project locally, then add our repository as `upstream` remote:
```
git remote add upstream https://github.com/breezy-weather/breezy-weather
```
Create a new branch for your pull request, for example:
```
git checkout -B mynewprovider
```
You can start working on it!
### Submit
Since you started working on your pull request, many commits might have been added, so you will need to rebase:
```
git fetch upstream
git rebase upstream main
```
(it it cant find `upstream`, check instructions at the top of this document)
If you are working on a new provider, you will usually not have any conflict, unless a new provider was added in the meantime in `SourceManager`, but in that case, you will find it easy to fix the conflict.
Then, you can push (with `--force` argument as you are rewriting history).
Please test your changes and if it works and you made multiple commits, please stash them as it makes reviewing easier. For example, if you made 2 commits, you can use:
```
git reset --soft HEAD~2
```
You can make a new commit, and once again, push your changes adding the `--force` argument.
## Weather sources
### Create a new Weather source
Choose a unique identifier for your weather source, with only lowercase letters. Examples:
- AccuWeather becomes `accu`
- Open-Meteo becomes `openmeteo`
Copy:
```
app/src/main/java/org/breezyweather/sources/pirateweather/
```
to:
```
app/src/main/java/org/breezyweather/sources/<yoursourceid>/
```
We will use Pirate Weather as a base as it is the most “apply to most situations” source, without having too many specific code that most sources dont need.
But at each step, you can have a look at what already exists for this source if you feel like something you want to implement might already have been done on other sources.
### API key (optional)
If you need an API key or any kind of secret, you will to need declare it in `app/build.gradle` as `breezy.<yoursourceid>.key`.
Then declare the value in `local.properties` which is private and will not be committed.
### API
Lets edit the API interface, and only implement the forecast API as a starting point.
In `app/src/main/java/org/breezyweather/source/<yoursourceid>/json/<technicalname>`, add the data class that will be constructed from the json returned by the API.
Use `@SerialName` when the name of the field is not the same as what is in the json returned by the API.
Example:
```kotlin
@SerialName("is_day") val isDay: Boolean?
```
As in the example, make as many fields as possible nullable so that in case the API doesnt return some fields for some locations, it doesnt fail. The serializer is configured to make nullable fields null in case the field is not in the JSON response, so you dont need to declare `= null` as default value.
### Service and converter
Rename `PirateWeatherService` with your source name and completes basic information.
As a starting point, we will only implement weather part, but here is the full list of interfaces/classes you can implement:
| Class/Interface | Use case |
|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `HttpSource()` | Currently does nothing except requiring to provide a link to privacy policy, which will be mandatory to accept in the future |
| `WeatherSource` | Your source can provide weather data for a given lon/lat. If your source doesnt accept lon/lat but cities-only, you will have to implement `LocationParametersSource` |
| `LocationParametersSource` | Your source needs location parameters, such as the code of a city. This code can be found by calling an endpoint with lon/lat, or a station list can be fetch to find the nearest station given the coordinates. |
| `LocationSearchSource` | Your source is able to return a list of `Location` object from a query, containing at least the TimeZone of the location. If your source doesnt include TimeZone, dont implement it, and this will default to Open-Meteo location search |
| `ReverseGeocodingSource` | Your source is able to return one `Location` (you can pick the first one if you have many) from lon/lat. If you dont have this feature available, dont implement it and locations created with your source will only have lon/lat |
| `ConfigurableSource` | You want to allow your user to change preferences, for example API key. |
For most complex needs, always have a look at existing sources. If you need to add a new type of pollen for your source, please contact us first as it is a non-trivial change to the code.
In the `requestWeather()`, all properties of the `WeatherWrapper` are optional, so you can start implementing bit by bit, so you can easily test the first data.
Add your service in the constructor of the `SourceManager` class.
Youre done, you can try building the app and test that you have empty data.
**IMPORTANT**: please dont try to “calculate” missing data. For example, if you have hourly air quality available in your source, but not daily air quality, dont try to calculate the daily air quality from hourly data! The app already takes care of completing any missing data for you. And if you feel that something that could be completed is not, please open an issue and we will improve the app to do so for all sources.
**Additional note**: the Daily object expects two half days, which most sources dont provide.
As explained in other documents, the daytime half-day is expected from 06:00 to 17:59 and the nighttime half-day is expected from 18:00 to 05:59 (or 29:59 to keep current day notation).
- If your source has half days with different hours, please follow their recommendations (for example, ColorfulClouds uses 08:00 to 19:59 and 20:00 to 07:59 (or 31:59)).
- If your source has no half day, a typical mistake you can make is to put the minimum temperature of the day as temperature of the night. However, your source probably gives you the minimum temperature from the past overnight, not from the night to come, so make sure to pick the correct data!
Once your source is complete (you use all available data from the API and available in Breezy Weather), please rebase and submit it as a pull request (see instructions above). Please allow Breezy Weather maintainers to make adjustments (but we wont write the source for you, you will have to make significant implementation).

173
HELP.md Normal file
View file

@ -0,0 +1,173 @@
# Help / Frequently Asked Questions
- [Locations](#locations)
- [Troubleshooting errors](#troubleshooting-errors)
- [Weather updates](#weather-updates)
- [Launcher](#launcher)
- [Design](#design)
___
## Locations
### App shows “Current location” instead of the address
First of all, address lookup is absolutely not required to get an accurate forecast, since its based on your longitude and latitude, not on your address, which is a totally different process.
If its still an important matter to you, you can select an address lookup source in the location settings.
### How can I change sources for a location?
Just swipe from right to left on location list, or tap the pencil icon on top right.
___
## Troubleshooting errors
### “Invalid or incomplete data received from server” / “Location search failed” / “Weather data refresh failed” / “Weather data refresh for a secondary weather source failed”
The source may be temporarily unavailable, please retry a few hours later. If the problem persists, please open an issue on GitHub.
### “Request timed out”
The source may be temporarily unavailable, please retry later or check your network. If the problem persists and you use a custom DNS, VPN or have a firewall, please check them as well.
### “Required API key missing” / “API requests limit reached” / “API access unauthorized” / “Update not yet available”
For most sources, we only have a limited number of calls allowed for free for all users of our app. If too many users use the same source, the only way to be able to continue using it is to check instructions on the source website to have your own API key. This may be troublesome, but if you have your own API key, the rate-limit will only apply to you (one user vs all users of Breezy Weather).
Regarding the “API access unauthorized”, this error may appear when you subscribed to the wrong product, or youre trying to use features of the API that your subscription doesnt allow.
### “Weather source failed to find a matching location”
This error happens when app was able to find your longitude and latitude, but unfortunately, the weather source did not find any location close to this longitude and latitude. Unfortunately, the only workaround is to try with a different source or add your location manually.
### “Failed to parse weather data”
This error should be reported as soon as possible to GitHub, mentioning the source and the location on which it is happening (or for privacy reasons, a nearby location that has the same issue).
### “Incompatible forecast source times”
This error means that the hourly times of forecast data and your air quality or pollen data dont match.
This can happen in the following case:
You live in India, with a timezone of UTC+05:30.
The forecast source you selected reports hourly forecast on the :00 time, while your air quality or pollen source reports on the :30 time (or the other way around).
### “Source no longer available”
This error may happen when a source is no longer provided by Breezy Weather. In that case, you will need to add a new location with another source, and delete this location. It can also happen when you switch from the standard flavor of Breezy Weather to `freenet` one which has less sources supported.
### “Secure connection failed”
This can mean many things.
If this only happens with one source and not others:
1) If you are using an Android version lower than Android 14, it is possible the server is using a Certificate Authority that was not trusted by the old Android version back then. On Android 14 and later, an updated trust store should be available to Google Play users. Note that we have our own bundled trust store in the app, where we can add missing Certificate Authorities.
2) If you have a low Android version, the server may be communicating with a more modern protocol or cipher suites than is supported by your device
3) The certificate may be expired. In that case, all users are affected, and the source will probably fix it very soon as this means no one can use the source (in any project, not just Breezy Weather)
If this is happening will all sources, and presumably with other apps, in the worst case, you may be a victim of a [man-in-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack).
If in doubt, [start a discussion to ask for help](https://github.com/breezy-weather/breezy-weather/discussions/new?category=general).
___
## Weather updates
### Background updates are not working
If the app is installed in a work or private profile, turning off that profile will disable background updates, so if you are in that case and want background updates, make sure to not turn off the profile or move the app to the main profile.
Certain manufacturers implement non-standard Android behaviors, which prevents the app from working properly.
The first thing to try is to whitelist Breezy Weather from battery optimization. From the app, go to Settings > Background updates and tap on “Disable battery optimization” (dont worry, our background update job is optimized to be very battery-friendly, and you can change “Refresh rate” to “Never” at any time!).
If it still doesnt work, you can find ways to circumvent aggressive manufacturer behaviors on the [Dont kill my app! website](https://dontkillmyapp.com/).
### I used Geometric Weather before, and the “persistent notification” method worked fine for me, can you bring it back?
If you dont already have a widget, you can try adding one (you dont need to have it on your main page). On some devices, this may help mimic the old “persistent notification” by avoiding app being killed for no reason, although the widget does not run anything like the old method, it just renders once and updates only on background updates or force refresh from the app.
Otherwise, this “persistent notification” method was based on a foreground service which was running every minute to check if there was something to do.
It is not battery-friendly at all. The worker method that we use just tells Android "we need something to run a task every 1 h 30, but if you are too busy to run it at that moment, you have a 10 minute margin to run it", so its much more efficient as Android takes care of running all jobs from all apps by itself at the moment it feels the most appropriate, instead of each app having their own foreground service.
If your manufacturer thinks its a good idea to not run scheduled workers but has no problem letting foreground services drain battery, then the problem is the manufacturer, not Breezy Weather, not you.
So we will not bring back/implement “persistent notification” for these reasons:
- it implies writing huge duplicate code (that was known to have duplicate run issues in Geometric Weather, btw) and maintaining it
- it is not battery-friendly
But more generally, we recommend that you follow steps from “Background updates are not working” section to find a workaround.
### Can you make weather refresh less than every 30 minutes / every time I open the app / every time I tap on widget / every time I unlock my phone / every second?
Short answer: no.
Long answer:
Breezy Weather should honor the “refresh rate” setting from Settings > Background updates. If it does not, have a look at troubleshooting above.
If for any reason the background update failed, it will refresh if weather was updated more than “refresh rate time” ago.
If you still want shorter refreshes:
- models are refreshed at best once an hour. Although there might be some little exceptions for some particular data, its mostly useless to refresh at intervals less than 30 minutes. Additionally, some providers send header instructions to not contact server again before X (datetime) so you would be served the same cached data anyway.
- we ask for fair usage of API and resources. This app and these API are provided for free and shared by all users of Breezy Weather. Due to noticed abuse, we even had to implement additional caching methods to prevent these abuses and ensure API can still be used by everyone.
- you can still force refresh from main screen by “swiping to refresh”.
___
## Launcher
### Why is the app not called “Breezy Weather” on my launcher?
The app name is “Breezy Weather”, however in the launcher we use the translated word for “weather”.
The rationale behind this is to offer a better user experience:
- You dont have to recall what was the app name to find it in the list. You just have to remind you want to access the weather.
- It better adapts to other languages, as we use the translated word for “weather” and you dont have to recall a non-native word (Breezy).
This choice is aligned with Breezy Weather principles to make it easy to use as a new user. Many other apps make the same decision.
For users with advanced needs not happy with this choice, we recommend using a launcher that allows customisation of app names.
___
## Design
### I hate the new Material 3 Expressive design update
As always when there is a major design change, there are early adopters who fully embrace the changes, and more conservative users, with the majority of users being in-between.
If youve been using the new design only for a few days, we encourage to **give yourself a few more weeks**.
Here is why:
#### Research
Material 3 Expressive is the **most studied design system** by Google, with 46 studies involving more than 18,000 participants.
Top research takeaways include:
1. Expressive designs are **preferred by people of all ages**, with a strong preference from users in the 18-34 age group.
2. Expressive designs consistently score higher on user attributes like **playfulness**, **energy**, **creativity**, and **friendliness**, which give a positive perception of the app by users.
3. Users are **more likely to switch to products that use M3 Expressive** components and techniques.
4. Expressive designs are **easier to use**, with participants spotting key UI elements up to **four times faster** in expressive screens, among users with varying abilities.
[Learn more about the Expressive research](https://design.google/library/expressive-material-design-google-research)
Each new design made by Google is more studied, and always gradually adopted by apps (we almost never see the old Holo design in apps anymore).
With clear design toolkit guidelines, Material 3 Expressive provides a **consistent developer and user experience** across the Android system and the other apps also adopting it.
#### Alternative design options
Regarding the ability to make an option to switch between the new and old design, its not possible, because there were significant technical changes during the migration to get rid of most of the technical debt, which allows for easier maintenance of the app.
Maintaining more than 1 design has a high maintenance cost. We do provide some abilities to customize this design for flexibility, but for more significant changes, we provide the [ability to make 3rd party designs](https://github.com/breezy-weather/breezy-weather/discussions/2089), either by yourself or by commissioning someone. If you concretize it, we would be happy if you could share it in the [Show & Tell section](https://github.com/breezy-weather/breezy-weather/discussions/categories/show-and-tell)!

82
INSTALL.md Normal file
View file

@ -0,0 +1,82 @@
# Simple instructions
Go to [Releases page](https://github.com/breezy-weather/breezy-weather/releases) and download the file with the following format `breezy-weather-vX.Y.Z_standard.apk`.
Install it and youre done!
After adding your first location, you will be asked if you want to be notified of app updates. We highly recommend you enable it.
# Detailed instructions
## Flavors
The recommended flavor of **Breezy Weather** is the standard version. It is fully open source and contains no proprietary components.
For specific needs, we also offer a flavor with only free-network sources (libre and self-hostable): Open-Meteo, Pirate Weather, Bright Sky (DWD), Recosanté and ClimWeb (used by many African countries).
Both flavors are signed with the same signature, so you can easily try/switch between both.
## Sources to get Breezy Weather from
**Breezy Weather** releases are available from the following sources:
- **[GitHub releases](https://github.com/breezy-weather/breezy-weather/releases)** is where releases built by GitHub are published under APK format. Any Android device can install APK files without needing any particular app. If you have a GitHub account, you can subscribe to be notified of updates, however its more convenient to use a store app to track updates. Due to technical limitations, this is also the only source to provide architecture-specific APKs, although the difference between them and the universal APK is of a negligible 2 MB, so it should not be a criteria of choice.
- **[Breezy Weathers F-Droid repositories](https://github.com/breezy-weather/fdroid-repo/blob/main/README.md)** are maintained by Breezy Weather developers and get updates from a F-Droid client that doesnt support receiving updates from GitHub.
- **[Izzy F-Droid repository](https://apt.izzysoft.de/fdroid/index/info)** offers the standard flavor which is our recommended choice if you would like someone to independently review the app before it gets published. Updates are fast (less than 24 hours).
- **[F-Droid default repository](https://f-droid.org/packages/org.breezyweather/)** offers the flavor with only free-network sources. Updates are slower as it requires someone on F-Droid team to manually add new versions, as autoupdates cannot be enabled for technical reasons. If you decide to use this source and you want to report an issue, you will be asked to update to the latest version before making the report.
| Differences | GitHub releases | [F-Droid repo] Breezy Weather | [F-Droid repo] Izzy | [F-Droid repo] Default |
|----------------------------|-----------------|-------------------------------|------------------------|---------------------------|
| Available flavors | All | All | Standard | Free network sources-only |
| Pre-releases | Optional | Optional | ❌ | ❌ |
| Delay for updates | Immediate | Immediate | Every day at 18:00 UTC | Very slow (manual) |
| APK matches source code | ✅ | ✅ | ✅ | ✅ |
| Independently reviewed | ❌ | ❌ | ✅ | ✅ |
| Architecture-specific APKs | ✅ | ❌ | ❌ | ❌ |
### Other not supported well-known sources
- Google Play Store:
- Costs money
- Is privacy invasive for the developer (requires sending your ID and giving your phone number)
- We dont [comply with Google Play policy](https://github.com/breezy-weather/breezy-weather/issues/31)
- Accrescent: waiting for it to become stable (no ETA announced by upstream)
## Client configuration instructions
### Obtainium
[Link to Obtainium page](https://github.com/ImranR98/Obtainium/blob/main/README.md)
#### Getting updates from GitHub releases
In the “Add App” screen:
1. Add the following URL: `https://github.com/breezy-weather/breezy-weather`
2. To receive updates for prereleases, enable “Include prereleases”
3. (Optional) If you want the flavor with only free network sources, add `freenet` in the “Filter APKs by Regular Expression”
4. Tap the “Add” button at the very top, and youre done!
#### Getting updates from a F-Droid repository
In the “Add App” screen, just add as App Source URL the following URL depending on the repository you want to use:
- Standard flavor from Izzy repo: `https://apt.izzysoft.de/fdroid/index/apk/org.breezyweather`
- Standard flavor from Breezy Weather repo: configure Obtainium to use GitHub releases instead (see previous section)
- Free-net flavor from Breezy Weather repo: configure Obtainium to use GitHub releases instead (see previous section)
- Free-net flavor from default F-Droid repo: `https://f-droid.org/packages/org.breezyweather/`
Tap the “Add” button at the very top, and youre done!
### F-Droid client
1) Look for the Repositories option from your F-Droid client and add a new repository depending on the source you want to use:
- Standard flavor from Izzy repo: `https://apt.izzysoft.de/fdroid/repo`
- Standard flavor from Breezy Weather repo: `https://breezy-weather.github.io/fdroid-repo/fdroid/repo`
- Free-net flavor from Breezy Weather repo: `https://breezy-weather.github.io/fdroid-repo/fdroid-version/fdroid/repo`
- Free-net flavor from default F-Droid repo: should already be enabled by default on your F-Droid client
2) After adding the app, go to the app details and make sure to select which repo you want to get updates from to avoid cross-updates between flavors and repos. This is how you do it:
![F-Droid preferred repo feature](docs/fdroid_client_config.png)

165
LICENSE Normal file
View file

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

3
LICENSE_ADDITIONAL Normal file
View file

@ -0,0 +1,3 @@
This License does not grant any rights in the trademarks, service marks, or logos of any Contributor.
Misrepresentation of the origin of that material is prohibited, and modified versions of such material must be marked in reasonable ways as different from the original version.

13
PRIVACY.md Normal file
View file

@ -0,0 +1,13 @@
Breezy Weather doesnt collect any personal data.
Optionally, it can access your approximate or precise location to find weather data for your position. This data is shared with weather sources. If you dont want to share your location, you can deny permissions and choose a city manually.
If you enable the “check for app updates” feature, the app will connect to GitHub every 24 hours at most to get the latest version of Breezy Weather. The version you currently have installed will not be shared with GitHub (it will be compared on device). [GitHub Privacy Policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement)
The icon packs feature (which allows you to customize weather icons) requires to list packages installed on your device. It will ignore all packages that dont match one of these packs: Breezy Weather icon pack, Geometric Weather icon pack, Chronus icon pack. No data is collected, it is only processed at the moment it is needed.
Breezy Weather relies on third-party APIs to get various data such as your current location (if you decide to not use native GPS), location search or weather, please review their privacy policy (from Settings > Info icon > Privacy policy) and only use the ones you agree with.
Breezy Weather can optionally share your location and weather data with other apps. Please review their privacy policy before you decide to grant them permission.
This privacy policy may be updated any time, for example to clarify a use case or in case a feature gets added. In that case, it will be mentioned in the changelog. You can always access the latest version of the privacy policy from Settings > Info icon > Privacy policy.

209
README.md
View file

@ -1,3 +1,208 @@
# breezy <div align="center">
<br />
<img src="app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp" alt="Logo" />
</div>
Android Weather App <h1 align="center">Breezy Weather</h1>
<br />
<div align="center">
<img alt="API 21+" src="https://img.shields.io/badge/Api%2021+-50f270?logo=android&logoColor=black&style=for-the-badge" />
<a href="https://kotlinlang.org/">
<img alt="Kotlin" src="https://img.shields.io/badge/Kotlin-a503fc?logo=kotlin&logoColor=white&style=for-the-badge" />
</a>
<a href="https://developer.android.com/compose">
<img alt="Jetpack Compose" src="https://img.shields.io/static/v1?style=for-the-badge&message=Jetpack+Compose&color=4285F4&logo=Jetpack+Compose&logoColor=FFFFFF&label=" />
</a>
<a href="https://m3.material.io/">
<img alt="Material 3 Expressive" src="https://custom-icon-badges.demolab.com/badge/m3%20expressive-lightblue?style=for-the-badge&logoColor=333&logo=material-you" />
</a>
<br />
<a href="https://github.com/breezy-weather/breezy-weather/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/breezy-weather/breezy-weather?style=for-the-badge" alt="License LGPL-3.0" />
</a>
<img src="https://img.shields.io/github/languages/code-size/breezy-weather/breezy-weather?style=for-the-badge" alt="GitHub code size in bytes" />
<br /><br />
<a href="https://github.com/breezy-weather/breezy-weather/releases">
<img src="https://img.shields.io/github/v/release/breezy-weather/breezy-weather?color=purple&include_prereleases&logo=github&style=for-the-badge" alt="Download from GitHub" />
</a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/org.breezyweather/">
<img src="https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/org.breezyweather?color=purple&include_prereleases&logo=FDROID&style=for-the-badge" alt="Download from IzzyOnDroid repo" />
</a>
<a href="https://f-droid.org/packages/org.breezyweather/">
<img src="https://img.shields.io/f-droid/v/org.breezyweather?color=purple&include_prereleases&logo=FDROID&style=for-the-badge" alt="Download from F-Droid default repo" />
</a>
</div>
<h4 align="center">Breezy Weather is a feature-rich free and open source Material 3 Expressive weather app with well-though-out visualizations, supporting forecast, observations, nowcasting, air quality, pollen, alerts, from more than 50 weather sources.</h4>
<div align="center">
# Download
<a href="https://github.com/breezy-weather/breezy-weather/releases">
<img src="https://user-images.githubusercontent.com/69304392/148696068-0cfea65d-b18f-4685-82b5-329a330b1c0d.png"
alt="Get it on GitHub" align="center" height="80" /></a>
<a href="https://github.com/breezy-weather/breezy-weather/blob/main/INSTALL.md#obtainium">
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
alt="Get it on Obtainium" align="center" height="54" />
</a>
<a href="https://github.com/breezy-weather/breezy-weather/blob/main/INSTALL.md#f-droid-client">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<p><a href="https://github.com/breezy-weather/breezy-weather/blob/main/INSTALL.md"><strong>All installation methods</strong></a></p>
</div>
<div align="center">
<p><strong>SHA-256 hash of the signing certificate:</strong> 29d435f70aa9aec3c1faff7f7ffa6e15785088d87f06ecfcab9c3cc62dc269d8<br />
SHA-256 checksums are also provided per file on the <a href="https://github.com/breezy-weather/breezy-weather/releases">GitHub releases page</a>.</p>
</div>
<hr />
<div align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-main-header-light.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-main-header-dark.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-main-blocks-1.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-main-blocks-2.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-settings.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-sources.png" alt="" style="width: 300px" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-details.png" alt="" style="width: 300px" />
</div>
# Features
- Weather data
- Daily and hourly forecasts up to 16 days
- Precipitation in the next hour
- Severe weather and precipitation alerts
- Temperature / Feels like temperature / Normals
- Precipitation
- Wind
- Air quality
- Pollen & Mold
- Humidity
- UV index
- Visibility
- Pressure
- Sun
- Moon
- Visualization
- Detailed 24-hour charts
- Material 3 Expressive blocks
- More than 50 weather sources supported (<a href="docs/SOURCES.md">full list</a>)
- Large selection of widgets
- Live wallpaper
- Custom icon packs
- [Geometric Weather icon packs](https://github.com/breezy-weather/breezy-weather-icon-packs/blob/main/README.md)
- Chronus Weather icon packs
- Automatic dark mode
- Opt-in data sharing with other apps (such as Gadgetbridge)
- <details><summary>Accessibility</summary>
- Localization
- Number formatting (different numeral systems, decimal separator, thousand separator)
- Unit formatting
- Alternate calendar
- Readability
- Good content descriptions for screen readers
- Navigation with screen readers: most things should work, features depending on drag & drop not yet supported
- Custom display settings: basic support
</details>
- <details><summary>Free and Open Source</summary>
- No proprietary blobs/dependencies
- Releases generated by GitHub actions, guaranteeing it matches the source code
- Fully works with Open-Meteo (FOSS source)
</details>
- <details><summary>Privacy-friendly</summary>
- No personal data collected by the app ([link to app privacy policy](https://github.com/breezy-weather/breezy-weather/blob/main/PRIVACY.md))
- Multiple sources are available, with links to their privacy policies for transparency
- Current location is optional and not added by default
- If using current location, an IP location service can be used instead of GPS to send less accurate coordinates to weather source
- No trackers/automatic crash reporters
</details>
# Help
* [Frequently Asked Questions / Help](HELP.md)
* [Main screen explanations](docs/HOMEPAGE.md)
* [Weather sources comparison](docs/SOURCES.md)
# Contribute
Pull requests are welcome. You can have a look at [issues opened to contributions](https://github.com/breezy-weather/breezy-weather/issues?q=is%3Aissue+is%3Aopen+label%3A%22Open+to+contributions%22). For other changes, please open an issue first to discuss what you would like to change.
* [Contribution guide (includes a guide to create a new weather source)](CONTRIBUTE.md)
## Features currently being worked on by a contributor
- [Announcement](https://github.com/breezy-weather/breezy-weather/discussions/2089) - Make Breezy weather data available through a ContentProvider. Currently in testing phase
## Features lacking an active contributor
- [#10](https://github.com/breezy-weather/breezy-weather/issues/10) - “Add location” page needs a new design, in the spirit of Google Maps where you can select location points on the map, or search manually - No mockup done yet
- [#937](https://github.com/breezy-weather/breezy-weather/issues/937) - Widget overhaul (prerequisite for any new widget improvement) - Some mockups were done but no one is working on it anymore
## Features that will not be implemented
- Paid-only sources, too limited free-tier, or free-tier that requires privacy-invasive information (credit card info, phone number, etc)
- Radar; [please check out this document for alternatives](docs/RADAR.md)
- Adding `standard` flavor or non-free sources to the F-Droid default repo: please use the `standard` flavor from a different store/source instead
- Changes to the [background updates process](docs/UPDATES.md), including but not limited: options for refreshing less than every 30 minutes, every time you open the app, every time you tap on widget, every time you unlock your phone
- “Circular sky” interface: please set a fixed background per location instead
- Publish to Google Play Store: please [check alternatives](INSTALL.md)
- Allow different flavors to be installed in parallel
- Implement features that are no longer available in latest Android versions
- Backport features/fixes from latest Android versions to older Android versions
- Donations: if you have extra money to spare, consider [donating to Open-Meteo](https://github.com/sponsors/open-meteo) to support infrastructure costs and future developments (we currently lack a libre and gratis worldwide alternative for the following features: [Reverse geocoding](https://github.com/open-meteo/geocoding-api/issues/6), [Alerts](https://github.com/open-meteo/open-meteo/issues/351), [Normals](https://github.com/open-meteo/open-meteo/issues/361))
# Translations
Translation is done externally [on Weblate](https://hosted.weblate.org/projects/breezy-weather/breezy-weather-android/#information). Please read carefully project instructions if you want to help.
[![Translation progress report](https://hosted.weblate.org/widget/breezy-weather/breezy-weather-android/multi-auto.svg)](https://hosted.weblate.org/projects/breezy-weather/breezy-weather-android/#information)
English (and regional variants) and French translations are maintained by repo maintainers, but they are open to proofreading/improvements. You will need to make a pull request, as we didnt find a way to make these languages in suggestion-only mode in Weblate (let us know if you find anything).
For unit formatting, we use [Unicode data](https://www.unicode.org/cldr/charts/47/summary/root.html) as much as possible. If you believe there is an error, please [open a discussion](https://github.com/breezy-weather/breezy-weather/discussions/categories/general) with evidences that the changes you suggest is the recommendation for your language.
# Contact us
* If youd like to report a bug or suggest a new feature, GitHub discussions or issues are best for organization.
* Weve also created a Matrix/Element space with a number of different channels for more general discussion: [`#breezy-weather-space:matrix.org`](https://matrix.to/#/#breezy-weather-space:matrix.org).
* If you are not comfortable writing a GitHub discussion/issue in English, you can ask on the channel if someone can help you in your language.
* We also have a dedicated help channel in French: [`#breezy-weather-francais:matrix.org`](https://matrix.to/#/#breezy-weather-francais:matrix.org)
* If youd prefer a direct channel link instead of a space link, heres the main Breezy Weather Matrix channel: [`#breezy-weather:matrix.org`](https://matrix.to/#/#breezy-weather:matrix.org)
# License
* [GNU Lesser General Public License v3.0](/LICENSE)
* This License does not grant any rights in the trademarks, service marks, or logos of any Contributor.
* Misrepresentation of the origin of that material is prohibited, and modified versions of such material must be marked in reasonable ways as different from the original version.
Before creating a fork, check if the intent action `nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER` can cover your need (for example, you want to re-use our weather data in your own customized widget). It can be enabled from Settings > Widgets & Live Wallpaper > Data sharing. You can also [help testing our `ContentProvider` exposing the full weather data of Breezy Weather](https://github.com/breezy-weather/breezy-weather/discussions/2089).
Otherwise, remember to:
- Respect the projects LICENSE
- Avoid confusion with Breezy Weather app:
- Change the app name
- Change the app icon
- Avoid installation conflicts:
- Change the `applicationId` in [`build.gradle.kts`](https://github.com/breezy-weather/breezy-weather/blob/main/app/build.gradle.kts#L24)

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)
}

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