Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions addons/weatherline/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.gradle/
build/
app/build/
local.properties
29 changes: 29 additions & 0 deletions addons/weatherline/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
plugins {
id 'com.android.application'
}

android {
namespace 'io.github.sumyabazar1.weatherline'
compileSdk 34

defaultConfig {
applicationId 'io.github.sumyabazar1.weatherline'
minSdk 26
targetSdk 34
versionCode 1
versionName '1.0'
}

buildTypes {
release {
minifyEnabled false
// Sideloaded over ADB like the launcher: signed with the debug key.
signingConfig signingConfigs.debug
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
35 changes: 35 additions & 0 deletions addons/weatherline/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Open-Meteo only: the forecast the line shows. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<application
android:allowBackup="false"
android:label="@string/app_name"
android:theme="@android:style/Theme.NoDisplay">

<!-- The launcher reads content://io.github.sumyabazar1.weatherline/today and shows the "line" column.
The provider answers from the last cached reading, so the line is there at once after a reboot
even before the network is back; a refresh runs in the background. -->
<provider
android:name=".WeatherProvider"
android:authorities="io.github.sumyabazar1.weatherline"
android:exported="true" />

<service
android:name=".WeatherRefreshJob"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />

<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.github.sumyabazar1.weatherline;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

/** A periodic job is dropped by a power cut on some firmware: put it back at boot. */
public final class BootReceiver extends BroadcastReceiver {

@Override
public void onReceive(Context context, Intent intent) {
WeatherRefreshJob.schedule(context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.sumyabazar1.weatherline;

import org.json.JSONObject;

import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;

/** One call to Open-Meteo for the place in strings.xml, reduced to the text the launcher shows. */
final class OpenMeteo {

private OpenMeteo() {
}

/** "Brisbane 21° Partly cloudy", or null when the request or the answer is unusable. */
static String fetch(String place, String lat, String lon, String timezone) {
HttpURLConnection c = null;
try {
String url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + lat + "&longitude=" + lon
+ "&current=temperature_2m,weather_code&timezone=" + timezone;
c = (HttpURLConnection) new URL(url).openConnection();
c.setConnectTimeout(10000);
c.setReadTimeout(10000);
c.setRequestProperty("Accept", "application/json");
if (c.getResponseCode() != 200) return null;
try (InputStream in = c.getInputStream()) {
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int n;
while ((n = in.read(buf)) > 0) sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
JSONObject current = new JSONObject(sb.toString()).getJSONObject("current");
double temp = current.getDouble("temperature_2m");
int code = current.getInt("weather_code");
return place + " " + Math.round(temp) + "° " + describe(code);
}
} catch (Exception e) {
return null;
} finally {
if (c != null) c.disconnect();
}
}

/** WMO weather code to a short phrase, as Open-Meteo documents them. */
private static String describe(int code) {
if (code == 0) return "Clear";
if (code == 1) return "Mostly clear";
if (code == 2) return "Partly cloudy";
if (code == 3) return "Overcast";
if (code == 45 || code == 48) return "Fog";
if (code >= 51 && code <= 57) return "Drizzle";
if (code >= 61 && code <= 67) return "Rain";
if (code >= 71 && code <= 77) return "Snow";
if (code >= 80 && code <= 82) return "Showers";
if (code == 85 || code == 86) return "Snow showers";
if (code >= 95) return "Storms";
return "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.github.sumyabazar1.weatherline;

import android.content.Context;
import android.content.SharedPreferences;

/**
* The last reading, kept on disk so the line is on screen the moment Home comes back.
*
* A TV that has never had a network shows the placeholder until the first successful fetch.
*/
final class WeatherCache {

private static final String PREFS = "weather-line";
private static final String KEY_LINE = "line";
private static final String KEY_AT = "at";
/** 30 minutes: Open-Meteo's own model updates about that often, and the launcher polls every 15. */
static final long FRESH_MS = 30 * 60 * 1000L;

private WeatherCache() {
}

private static SharedPreferences prefs(Context ctx) {
return ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}

static String line(Context ctx) {
return prefs(ctx).getString(KEY_LINE, "");
}

static boolean isFresh(Context ctx) {
long at = prefs(ctx).getLong(KEY_AT, 0L);
return at > 0 && System.currentTimeMillis() - at < FRESH_MS;
}

static void save(Context ctx, String line) {
prefs(ctx).edit().putString(KEY_LINE, line)
.putLong(KEY_AT, System.currentTimeMillis()).apply();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.github.sumyabazar1.weatherline;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;

/**
* content://io.github.sumyabazar1.weatherline/today, one row with a "line" column.
*
* It never goes online itself: the answer is the cached reading, returned at once, and a stale cache
* starts a background refresh for the next read. That keeps the launcher's first frame fast and lets the
* line survive a reboot before the network is back.
*/
public final class WeatherProvider extends ContentProvider {

static final String AUTHORITY = "io.github.sumyabazar1.weatherline";
private static final String[] COLUMNS = {"line"};

@Override
public boolean onCreate() {
WeatherRefreshJob.schedule(getContext());
return true;
}

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
Context ctx = getContext();
MatrixCursor cursor = new MatrixCursor(COLUMNS, 1);
String line = ctx == null ? "" : WeatherCache.line(ctx);
cursor.addRow(new Object[]{line == null ? "" : line});
if (ctx != null && !WeatherCache.isFresh(ctx)) WeatherRefreshJob.runNow(ctx);
return cursor;
}

@Override
public String getType(Uri uri) {
return "vnd.android.cursor.item/weather-line";
}

@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException();
}

@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException();
}

@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.github.sumyabazar1.weatherline;

import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.content.ComponentName;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.AsyncTask;

/** Fetches the forecast in the background, and only when there is a network to fetch it over. */
public final class WeatherRefreshJob extends JobService {

private static final int JOB_ID = 4711;

static void schedule(Context ctx) {
JobScheduler js = ctx.getSystemService(JobScheduler.class);
if (js == null) return;
JobInfo job = new JobInfo.Builder(JOB_ID, new ComponentName(ctx, WeatherRefreshJob.class))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setPeriodic(30 * 60 * 1000L)
.setPersisted(true)
.build();
js.schedule(job);
}

/** One refresh now, for a cache the provider found stale. Cheap enough to run on any query. */
static void runNow(Context ctx) {
JobScheduler js = ctx.getSystemService(JobScheduler.class);
if (js == null) return;
JobInfo job = new JobInfo.Builder(JOB_ID + 1, new ComponentName(ctx, WeatherRefreshJob.class))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
.setOverrideDeadline(0)
.build();
js.schedule(job);
}

@Override
public boolean onStartJob(JobParameters params) {
Context ctx = getApplicationContext();
if (!online(ctx)) {
return false; // the job's network requirement normally prevents this
}
new AsyncTask<Void, Void, String>() {
@Override
protected String doInBackground(Void... voids) {
return OpenMeteo.fetch(ctx.getString(R.string.place),
ctx.getString(R.string.latitude),
ctx.getString(R.string.longitude),
ctx.getString(R.string.timezone));
}

@Override
protected void onPostExecute(String line) {
if (line != null && !line.isEmpty()) WeatherCache.save(ctx, line);
jobFinished(params, false);
}
}.execute();
return true;
}

@Override
public boolean onStopJob(JobParameters params) {
return false;
}

private static boolean online(Context ctx) {
ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
if (cm == null) return false;
Network n = cm.getActiveNetwork();
if (n == null) return false;
NetworkCapabilities caps = cm.getNetworkCapabilities(n);
return caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
}
}
10 changes: 10 additions & 0 deletions addons/weatherline/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Weather line</string>

<!-- The place the line shows. Edit these three and rebuild to move it. -->
<string name="place" translatable="false">Brisbane</string>
<string name="latitude" translatable="false">-27.4698</string>
<string name="longitude" translatable="false">153.0251</string>
<string name="timezone" translatable="false">Australia/Brisbane</string>
</resources>
3 changes: 3 additions & 0 deletions addons/weatherline/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
}
1 change: 1 addition & 0 deletions addons/weatherline/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
Binary file not shown.
8 changes: 8 additions & 0 deletions addons/weatherline/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading