/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.apps.dashclock.weather;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.location.Criteria;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.location.LocationProvider;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import com.google.android.apps.dashclock.LogUtils;
import com.google.android.apps.dashclock.api.DashClockExtension;
import com.google.android.apps.dashclock.api.ExtensionData;
import com.google.android.apps.dashclock.configuration.AppChooserPreference;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GooglePlayServicesUtil;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks;
import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;
import net.nurik.roman.dashclock.BuildConfig;
import net.nurik.roman.dashclock.R;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
import static com.google.android.apps.dashclock.LogUtils.LOGD;
import static com.google.android.apps.dashclock.LogUtils.LOGE;
import static com.google.android.apps.dashclock.LogUtils.LOGW;
import static com.google.android.apps.dashclock.Utils.MINUTES_MILLIS;
import static com.google.android.apps.dashclock.Utils.MILLIS_NANOS;
import static com.google.android.apps.dashclock.Utils.SECONDS_MILLIS;
import static com.google.android.apps.dashclock.weather.YahooWeatherApiClient.LocationInfo;
import static com.google.android.apps.dashclock.weather.YahooWeatherApiClient.getLocationInfo;
import static com.google.android.apps.dashclock.weather.YahooWeatherApiClient.getWeatherForLocationInfo;
import static com.google.android.apps.dashclock.weather.YahooWeatherApiClient.setWeatherUnits;
import static com.google.android.gms.location.LocationServices.FusedLocationApi;
/**
* A local weather and forecast extension.
*/
public class WeatherExtension extends DashClockExtension {
private static final String TAG = LogUtils.makeLogTag(WeatherExtension.class);
public static final String ACTION_RECEIVED_LOCATION
= "com.google.android.apps.dashclock.action.RECEIVED_LOCATION";
public static final String PREF_WEATHER_UNITS = "pref_weather_units";
public static final String PREF_WEATHER_SHORTCUT = "pref_weather_shortcut";
public static final String PREF_WEATHER_LOCATION = "pref_weather_location";
public static final Intent DEFAULT_WEATHER_INTENT = new Intent(Intent.ACTION_VIEW,
Uri.parse("https://www.google.com/search?q=weather"));
public static final String STATE_WEATHER_LAST_BACKOFF_MILLIS
= "state_weather_last_backoff_millis";
public static final String STATE_WEATHER_LAST_UPDATE_ELAPSED_MILLIS
= "state_weather_last_update_elapsed_millis";
// At least 10 min b/w updates
private static final int UPDATE_THROTTLE_MILLIS = 10 * MINUTES_MILLIS;
private static final long STALE_LOCATION_NANOS = 10 * MINUTES_MILLIS * MILLIS_NANOS;
// 30 seconds for first error retry
private static final int INITIAL_BACKOFF_MILLIS = 30 * SECONDS_MILLIS;
// 60 sec timeout for location attempt
private static final int LOCATION_TIMEOUT_MILLIS = 60 * SECONDS_MILLIS;
private static final Criteria sLocationCriteria;
private GoogleApiClient mLocationClient;
private LocationRequest mLocationRequest;
private static String sWeatherUnits = "f";
private static Intent sWeatherIntent;
private Handler mServiceThreadHandler;
private boolean mOneTimeLocationListenerActive = false;
private Handler mTimeoutHandler = new Handler();
static {
sLocationCriteria = new Criteria();
sLocationCriteria.setPowerRequirement(Criteria.POWER_LOW);
sLocationCriteria.setAccuracy(Criteria.ACCURACY_COARSE);
sLocationCriteria.setCostAllowed(false);
}
private void resetAndCancelRetries() {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
sp.edit().remove(STATE_WEATHER_LAST_BACKOFF_MILLIS).apply();
AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE);
am.cancel(WeatherRetryReceiver.getPendingIntent(this));
}
private void scheduleRetry() {
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
int lastBackoffMillis = sp.getInt(STATE_WEATHER_LAST_BACKOFF_MILLIS, 0);
int backoffMillis = (lastBackoffMillis > 0)
? lastBackoffMillis * 2
: INITIAL_BACKOFF_MILLIS;
sp.edit().putInt(STATE_WEATHER_LAST_BACKOFF_MILLIS, backoffMillis).apply();
LOGD(TAG, "Scheduling weather retry in " + (backoffMillis / SECONDS_MILLIS) + " second(s)");
AlarmManager am = (AlarmManager) getSystemService(ALARM_SERVICE);
am.set(AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + backoffMillis,
WeatherRetryReceiver.getPendingIntent(this));
}
@Override
protected void onInitialize(boolean isReconnect) {
super.onInitialize(isReconnect);
}
@Override
protected void onUpdateData(int reason) {
if (mServiceThreadHandler == null) {
// Get handle to background thread
mServiceThreadHandler = new Handler(Looper.myLooper());
}
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
sWeatherUnits = sp.getString(PREF_WEATHER_UNITS, sWeatherUnits);
sWeatherIntent = AppChooserPreference.getIntentValue(
sp.getString(PREF_WEATHER_SHORTCUT, null), DEFAULT_WEATHER_INTENT);
setWeatherUnits(sWeatherUnits);
long lastUpdateElapsedMillis = sp.getLong(STATE_WEATHER_LAST_UPDATE_ELAPSED_MILLIS,
-UPDATE_THROTTLE_MILLIS);
long nowElapsedMillis = SystemClock.elapsedRealtime();
if (reason != UPDATE_REASON_INITIAL && reason != UPDATE_REASON_MANUAL &&
nowElapsedMillis < lastUpdateElapsedMillis + UPDATE_THROTTLE_MILLIS) {
LOGD(TAG, "Throttling weather update attempt.");
return;
}
LOGD(TAG, "Attempting weather update; reason=" + reason);
NetworkInfo ni = ((ConnectivityManager) getSystemService(
Context.CONNECTIVITY_SERVICE)).getActiveNetworkInfo();
if (ni == null || !ni.isConnected()) {
LOGD(TAG, "No network connection; not attempting to update weather.");
return;
}
String manualLocationWoeid = WeatherLocationPreference.getWoeidFromValue(
sp.getString(PREF_WEATHER_LOCATION, null));
if (!TextUtils.isEmpty(manualLocationWoeid)) {
// WOEIDs
// Honolulu = 2423945
// Paris = 615702
// London = 44418
// New York = 2459115
// San Francisco = 2487956
LocationInfo locationInfo = new LocationInfo();
locationInfo.woeids = Arrays.asList(manualLocationWoeid);
tryPublishWeatherUpdateFromLocationInfo(locationInfo);
return;
}
// Get the user's location, then get the weather for that location.
tryGooglePlayServicesGetLocationAndPublishWeatherUpdate(new Runnable() {
@Override
public void run() {
// If there was an error with Play Services, try LocationManager
tryLocationManagerGetLocationAndPublishWeatherUpdate();
}
});
}
private void tryGooglePlayServicesGetLocationAndPublishWeatherUpdate(
final Runnable errorRunnable) {
int playServicesResult = GooglePlayServicesUtil.isGooglePlayServicesAvailable(this);
if (playServicesResult != ConnectionResult.SUCCESS) {
LOGW(TAG, "Google Play Services was unavailable (code " + playServicesResult + ").");
if (errorRunnable != null) {
errorRunnable.run();
}
return;
}
if (mLocationClient != null) {
// Already trying to obtain a location. Don't call error runnable since this isn't
// an error.
return;
}
LOGD(TAG, "Getting location using Google Play Services.");
mLocationClient = new GoogleApiClient.Builder(this)
.addApi(LocationServices.API)
.addConnectionCallbacks(new ConnectionCallbacks() {
@Override
public void onConnected(Bundle bundle) {
if (mServiceThreadHandler == null) {
LOGW(TAG, "Empty Service thread handler; Play Services unavailable.");
mLocationClient.disconnect();
mLocationClient = null;
if (errorRunnable != null) {
errorRunnable.run();
}
return;
}
mServiceThreadHandler.post(new Runnable() {
@Override
public void run() {
onHasLocation();
}
});
}
@Override
public void onConnectionSuspended(int i) {
}
private void onHasLocation() {
Location lastLocation = FusedLocationApi.getLastLocation(mLocationClient);
if (lastLocation == null || (SystemClock.elapsedRealtimeNanos()
- lastLocation.getElapsedRealtimeNanos()) >= STALE_LOCATION_NANOS) {
LOGW(TAG, "Stale or missing last-known location; requesting " +
"single location update.");
Intent intent = new Intent(
WeatherExtension.this, WeatherExtension.class);
intent.setAction(ACTION_RECEIVED_LOCATION);
PendingIntent locationPendingIntent = PendingIntent.getService(
WeatherExtension.this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT);
FusedLocationApi.requestLocationUpdates(mLocationClient,
mLocationRequest, locationPendingIntent);
// Schedule a retry if timing out. When the location request expires,
// updates will simply stop, and we won't get any notification of this,
// so handle it separately.
mTimeoutHandler.removeCallbacksAndMessages(null);
mTimeoutHandler.postDelayed(new Runnable() {
@Override
public void run() {
LOGE(TAG, "Play Services location request timed out.");
disableOneTimeLocationListener();
scheduleRetry();
}
}, LOCATION_TIMEOUT_MILLIS);
} else {
tryPublishWeatherUpdateFromGeolocation(lastLocation);
}
mLocationClient.disconnect();
mLocationClient = null;
}
})
.addOnConnectionFailedListener(new OnConnectionFailedListener() {
@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
mLocationClient = null;
if (errorRunnable != null) {
errorRunnable.run();
}
}
})
.build();
// Create a location request
mLocationRequest = LocationRequest.create()
.setExpirationDuration(LOCATION_TIMEOUT_MILLIS - 1000)
.setFastestInterval(0)
.setInterval(0)
.setNumUpdates(1)
.setSmallestDisplacement(0)
.setPriority(LocationRequest.PRIORITY_LOW_POWER);
// Connect to the location api
mLocationClient.connect();
}
private void tryLocationManagerGetLocationAndPublishWeatherUpdate() {
LOGD(TAG, "Getting location using LocationManager");
LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
String provider = lm.getBestProvider(sLocationCriteria, true);
if (TextUtils.isEmpty(provider)) {
publishErrorUpdate(new CantGetWeatherException(false, R.string.no_location_data,
"No available location providers matching criteria."));
return;
}
final Location lastLocation = lm.getLastKnownLocation(provider);
if (lastLocation == null ||
(SystemClock.elapsedRealtimeNanos() - lastLocation.getElapsedRealtimeNanos())
>= STALE_LOCATION_NANOS) {
LOGW(TAG, "Stale or missing last-known location; requesting single coarse location "
+ "update.");
disableOneTimeLocationListener();
mOneTimeLocationListenerActive = true;
lm.requestSingleUpdate(provider, mOneTimeLocationListener, null);
// Time-out single location update request
mTimeoutHandler.removeCallbacksAndMessages(null);
mTimeoutHandler.postDelayed(new Runnable() {
@Override
public void run() {
LOGE(TAG, "LocationManager location request timed out.");
disableOneTimeLocationListener();
scheduleRetry();
}
}, LOCATION_TIMEOUT_MILLIS);
} else {
tryPublishWeatherUpdateFromGeolocation(lastLocation);
}
}
private void disableOneTimeLocationListener() {
if (mOneTimeLocationListenerActive) {
LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
lm.removeUpdates(mOneTimeLocationListener);
mOneTimeLocationListenerActive = false;
}
}
private LocationListener mOneTimeLocationListener = new LocationListener() {
@Override
public void onLocationChanged(Location location) {
LOGD(TAG, "Got network location update");
mTimeoutHandler.removeCallbacksAndMessages(null);
tryPublishWeatherUpdateFromGeolocation(location);
disableOneTimeLocationListener();
}
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
LOGD(TAG, "Network location provider status change: " + status);
if (status == LocationProvider.TEMPORARILY_UNAVAILABLE) {
scheduleRetry();
disableOneTimeLocationListener();
}
}
@Override
public void onProviderEnabled(String provider) {
}
@Override
public void onProviderDisabled(String provider) {
}
};
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_RECEIVED_LOCATION.equals(intent.getAction())) {
final Location location = intent.getParcelableExtra(
LocationManager.KEY_LOCATION_CHANGED);
if (mServiceThreadHandler == null) {
LOGW(TAG, "Can't process location update because onUpdateData hasn't been called "
+ "on this service instance.");
} else if (location != null) {
// A location update request succeeded; try publishing weather from here.
LOGD(TAG, "Got a Play Services location update; trying weather update.");
mTimeoutHandler.removeCallbacksAndMessages(null);
mServiceThreadHandler.post(new Runnable() {
@Override
public void run() {
tryPublishWeatherUpdateFromGeolocation(location);
}
});
}
stopSelf(startId);
}
return super.onStartCommand(intent, flags, startId);
}
@Override
public void onDestroy() {
super.onDestroy();
if (mLocationClient != null) {
mLocationClient.disconnect();
}
mLocationClient = null;
disableOneTimeLocationListener();
}
private void tryPublishWeatherUpdateFromGeolocation(Location location) {
try {
LOGD(TAG, "Using location: " + location.getLatitude() + "," + location.getLongitude());
tryPublishWeatherUpdateFromLocationInfo(getLocationInfo(location));
} catch (CantGetWeatherException e) {
publishErrorUpdate(e);
if (e.isRetryable()) {
scheduleRetry();
}
}
}
private void tryPublishWeatherUpdateFromLocationInfo(LocationInfo locationInfo) {
try {
publishWeatherUpdate(getWeatherForLocationInfo(locationInfo));
} catch (CantGetWeatherException e) {
publishErrorUpdate(e);
if (e.isRetryable()) {
scheduleRetry();
}
}
}
private void publishErrorUpdate(CantGetWeatherException e) {
LOGE(TAG, "Showing a weather extension error", e);
publishUpdate(new ExtensionData()
.visible(true)
.clickIntent(sWeatherIntent)
.icon(R.drawable.ic_weather_clear)
.status(getString(R.string.status_none))
.expandedBody(getString(e.getUserFacingErrorStringId())));
}
private void publishWeatherUpdate(WeatherData weatherData) {
String temperature = (weatherData.temperature != WeatherData.INVALID_TEMPERATURE)
? getString(R.string.temperature_template, weatherData.temperature)
: getString(R.string.status_none);
StringBuilder expandedBody = new StringBuilder();
if (weatherData.low != WeatherData.INVALID_TEMPERATURE
&& weatherData.high != WeatherData.INVALID_TEMPERATURE) {
expandedBody.append(getString(R.string.weather_low_high_template,
getString(R.string.temperature_template, weatherData.low),
getString(R.string.temperature_template, weatherData.high)));
}
int conditionIconId = WeatherData.getConditionIconId(weatherData.conditionCode);
if (WeatherData.getConditionIconId(weatherData.todayForecastConditionCode)
== R.drawable.ic_weather_raining) {
// Show rain if it will rain today.
conditionIconId = R.drawable.ic_weather_raining;
if (expandedBody.length() > 0) {
expandedBody.append(", ");
}
expandedBody.append(
getString(R.string.later_forecast_template, weatherData.forecastText));
}
if (expandedBody.length() > 0) {
expandedBody.append("\n");
}
expandedBody.append(weatherData.location);
if (BuildConfig.DEBUG) {
expandedBody.append("\n")
.append(SimpleDateFormat.getDateTimeInstance().format(new Date()));
}
publishUpdate(new ExtensionData()
.visible(true)
.clickIntent(sWeatherIntent)
.status(temperature)
.expandedTitle(getString(R.string.weather_expanded_title_template,
temperature + sWeatherUnits.toUpperCase(Locale.US),
weatherData.conditionText))
.icon(conditionIconId)
.expandedBody(expandedBody.toString()));
// Mark that a successful weather update has been pushed
resetAndCancelRetries();
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
sp.edit().putLong(STATE_WEATHER_LAST_UPDATE_ELAPSED_MILLIS,
SystemClock.elapsedRealtime()).commit();
}
}