/*
* Copyright 2015. Appsi Mobile
*
* 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.appsimobile.appsii.module.weather;
import android.Manifest;
import android.accounts.Account;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SyncResult;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.Point;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.Looper;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresPermission;
import android.support.v4.util.CircularArray;
import android.support.v4.util.SimpleArrayMap;
import android.text.format.DateUtils;
import android.util.Log;
import android.view.WindowManager;
import com.android.volley.VolleyError;
import com.appsimobile.appsii.BitmapUtils;
import com.appsimobile.appsii.BuildConfig;
import com.appsimobile.appsii.dagger.AppInjector;
import com.appsimobile.appsii.module.home.WeatherFragment;
import com.appsimobile.appsii.module.home.config.HomeItemConfiguration;
import com.appsimobile.appsii.module.weather.loader.CantGetWeatherException;
import com.appsimobile.appsii.module.weather.loader.WeatherData;
import com.appsimobile.appsii.module.weather.loader.YahooWeatherApiClient;
import com.appsimobile.appsii.permissions.PermissionUtils;
import com.appsimobile.appsii.preference.PreferenceHelper;
import com.appsimobile.util.ArrayUtils;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import javax.inject.Inject;
import static android.Manifest.permission.ACCESS_COARSE_LOCATION;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
/**
* Created by Nick on 19/02/14.
* Please note: This service is running in a different process. So do not
* use anything like shared-preferences, or anything backed by
* shared-preferences.
* <p/>
*/
public class WeatherLoadingService {
public static final int MAX_PHOTO_COUNT = 2;
public static final String PREFERENCE_LAST_KNOWN_WOEID = "last_known_woeid";
public static final String PREFERENCE_LAST_UPDATED_MILLIS =
BuildConfig.APPLICATION_ID + ".last_updated_millis";
public static final String EXTRA_INCLUDE_WOEID = BuildConfig.APPLICATION_ID + ".with_woeid";
public static final String EXTRA_UNIT = BuildConfig.APPLICATION_ID + ".unit";
public static final String ACTION_WEATHER_UPDATED =
BuildConfig.APPLICATION_ID + ".weather_updated";
final Context mContext;
@Inject
HomeItemConfiguration mConfigurationHelper;
@Inject
SharedPreferences mPreferences;
@Inject
PreferenceHelper mPreferenceHelper;
@Inject
ConnectivityManager mConnectivityManager;
@Inject
LocationManager mLocationManager;
@Inject
PermissionUtils mPermissionUtils;
@Inject
BitmapUtils mBitmapUtils;
@Inject
WindowManager mWindowManager;
WeatherUtils mWeatherUtils;
public WeatherLoadingService(Context context) {
mContext = context.getApplicationContext();
AppInjector.inject(this);
}
/**
* Returns true when the interval to request a sync has been expired.
* Normally this is determined in the sync adapter mechanism itself.
* But if it decides to stop syncing correctly, this method can
* determine if now would be a good time to call
* {@link ContentResolver#requestSync(Account, String, Bundle)} to
* make sure the weather data is up to date.
* <p/>
* Returns true when now is a good time to update the weatherdata.
*/
public static boolean hasTimeoutExpired(SharedPreferences preferences) {
long lastUpdate = preferences.getLong(PREFERENCE_LAST_UPDATED_MILLIS, 0);
long timePassedMillis = System.currentTimeMillis() - lastUpdate;
long minutesPassed = timePassedMillis / DateUtils.MINUTE_IN_MILLIS;
return minutesPassed > 45;
}
static void bailOut(String reason) {
Log.i("WeatherLoadingService", "not updating weather for reason: " + reason);
}
/**
* Downloads the header images for the given woeid and weather-data. Failure is considered
* non-fatal.
*
* @throws VolleyError
*/
public static void downloadWeatherImages(Context context, BitmapUtils bitmapUtils,
String woeid, WeatherData weatherData, String timezone) throws VolleyError {
WindowManager windowManager = AppInjector.provideWindowManager();
// first we need to determine if it is day or night.
// TODO: this needs the timezone
if (timezone == null) {
timezone = TimeZone.getDefault().getID();
}
WeatherUtils weatherUtils = AppInjector.provideWeatherUtils();
boolean isDay = weatherUtils.isDay(timezone, weatherData);
ImageDownloadHelper downloadHelper = ImageDownloadHelper.getInstance(context);
// call into the download-helper this will return a json object with
// city photos matching the current weather condition.
JSONObject photos = downloadHelper.searchCityWeatherPhotos(
woeid, weatherData.nowConditionCode, isDay);
// Now we need the screen dimension to know which photos have a usable size.
int dimen = getMaxScreenDimension(windowManager);
// determine the photos that can be used.
List<ImageDownloadHelper.PhotoInfo> result = new ArrayList<>();
ImageDownloadHelper.getEligiblePhotosFromResponse(photos, result, dimen);
// when no usable photos have been found try photos at the city level with
// no weather condition info.
if (result.isEmpty()) {
photos = downloadHelper.searchCityImage(woeid);
ImageDownloadHelper.getEligiblePhotosFromResponse(photos, result, dimen);
// when still no photo was found, clear the existing photos and return
if (result.isEmpty()) {
weatherUtils.clearCityPhotos(context, woeid, 0);
return;
}
}
// Now determine the amount of photos we should download
int N = Math.min(MAX_PHOTO_COUNT, result.size());
// idx keeps the index of the actually downloaded photo count
int idx = 0;
// note the idx < N instead of i < N.
// this loop must continue until the amount is satisfied.
for (int i = 0; idx < N; i++) {
// quit when the end of the list is reached
if (i >= result.size()) break;
// try to download the photo details from the webservice.
ImageDownloadHelper.PhotoInfo info = result.get(i);
JSONObject photoInfo = downloadHelper.loadPhotoInfo(context, info.id);
if (photoInfo != null) {
// we need to know if the photo is rotated. If so, we need to apply this
// rotation after download.
int rotation = ImageDownloadHelper.getRotationFromJson(photoInfo);
if (downloadFile(context, info, woeid, idx)) {
// Apply rotation when non zero
if (rotation != 0) {
File cacheDir = weatherUtils.getWeatherPhotoCacheDir(context);
String fileName = weatherUtils.createPhotoFileName(woeid, idx);
File photoImage = new File(cacheDir, fileName);
Bitmap bitmap =
bitmapUtils.decodeSampledBitmapFromFile(photoImage, dimen, dimen);
if (bitmap == null) {
Log.wtf("WeatherLoadingService", "error decoding bitmap");
continue;
}
Matrix matrix = new Matrix();
matrix.postRotate(rotation);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(),
bitmap.getHeight(),
matrix, false);
weatherUtils.saveBitmap(context, bitmap, woeid, idx);
}
// success, handle the next one.
idx++;
}
}
}
// remove photos at higher indexes than the amount downloaded.
weatherUtils.clearCityPhotos(context, woeid, idx + 1);
}
private static int getMaxScreenDimension(WindowManager windowManager) {
Point point = new Point();
windowManager.getDefaultDisplay().getSize(point);
int dimen = Math.max(point.x, point.y);
dimen = (dimen * 3) / 4;
return dimen;
}
private static boolean downloadFile(Context context,
ImageDownloadHelper.PhotoInfo photoInfo, String woeid, int idx) {
WeatherUtils weatherUtils = AppInjector.provideWeatherUtils();
File cacheDir = weatherUtils.getWeatherPhotoCacheDir(context);
String fileName = weatherUtils.createPhotoFileName(woeid, idx);
File photoImage = new File(cacheDir, fileName);
try {
URL url = new URL(photoInfo.url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(30000);
InputStream in = new BufferedInputStream(connection.getInputStream());
try {
OutputStream out = new BufferedOutputStream(new FileOutputStream(photoImage));
int totalRead = 0;
try {
byte[] bytes = new byte[64 * 1024];
int read;
while ((read = in.read(bytes)) != -1) {
out.write(bytes, 0, read);
totalRead += read;
}
out.flush();
} finally {
out.close();
}
if (BuildConfig.DEBUG) {
Log.d("WeatherLoadingService",
"received " + totalRead + " bytes for: " + photoInfo.url);
}
} finally {
in.close();
}
return true;
} catch (MalformedURLException e) {
e.printStackTrace();
return false;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
// if e.g. the location was changed, this is a forced update.
void doSync(String defaultUnit, String extraWoeid, SyncResult result) {
if (defaultUnit == null) throw new IllegalArgumentException("defaultUnit == null");
NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo();
boolean online = netInfo != null && netInfo.isConnected();
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "Handling sync");
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "Checking online");
if (!online) {
bailOut("No network connection");
result.stats.numIoExceptions++;
return;
}
boolean syncWhenRoaming = mPreferenceHelper.getSyncWhenRoaming();
if (netInfo.isRoaming() && !syncWhenRoaming) {
bailOut("Not syncing because of roaming connection");
result.stats.numIoExceptions++;
return;
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- Checking online");
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "get woeids");
String[] woeids = mConfigurationHelper.getWeatherWidgetWoeids(
WeatherFragment.PREFERENCE_WEATHER_WOEID);
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- get woeids");
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "extra woeids");
if (extraWoeid != null) {
int length = woeids.length;
String[] temp = new String[length + 1];
System.arraycopy(woeids, 0, temp, 0, length);
temp[length] = extraWoeid;
woeids = temp;
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- extra woeids");
if (woeids.length == 0) {
bailOut("Not syncing because there are no woeids");
// tell the service to reschedule normally
result.stats.numUpdates++;
return;
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "find timezones");
int N = woeids.length;
SimpleArrayMap<String, String> woeidTimezones = new SimpleArrayMap<>(N);
for (int i = 0; i < N; i++) {
String woeid = woeids[i];
long cellId = mConfigurationHelper.findCellWithPropertyValue(
WeatherFragment.PREFERENCE_WEATHER_WOEID, woeid);
if (cellId != -1) {
String timezone = mConfigurationHelper.
getProperty(cellId, WeatherFragment.PREFERENCE_WEATHER_TIMEZONE, null);
if (BuildConfig.DEBUG) {
Log.d("WeatherLoadingService",
"woeid -> timezone: " + woeid + " -> " + timezone);
}
woeidTimezones.put(woeid, timezone);
}
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- find timezones");
try {
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "request location");
Location location;
if (mPermissionUtils.holdsPermission(
mContext, Manifest.permission.ACCESS_COARSE_LOCATION)) {
location = requestLocationInfoBlocking();
} else {
woeids = addFallbackWoeid(woeids, woeidTimezones);
location = null;
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- request location");
SimpleArrayMap<String, WeatherData> previousData = new SimpleArrayMap<>(woeids.length);
for (String woeid : woeids) {
WeatherData data = mWeatherUtils.getWeatherData(mContext, woeid);
previousData.put(woeid, data);
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "load data");
WeatherDataLoader loader = new WeatherDataLoader(location, woeids, defaultUnit);
CircularArray<WeatherData> data = loader.queryWeather();
result.stats.numUpdates++;
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- load data");
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "sync images");
int size = data.size();
for (int i = 0; i < size; i++) {
WeatherData weatherData = data.get(i);
try {
syncImages(result, mConnectivityManager, mPreferenceHelper, woeidTimezones,
previousData,
weatherData);
} catch (VolleyError e) {
Log.w("WeatherLoadingService", "error getting images", e);
}
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "- sync images");
} catch (InterruptedException ignore) {
// we have been requested to stop, so simply stop
result.stats.numIoExceptions++;
} catch (CantGetWeatherException e) {
Log.e("WeatherLoadingService", "error loading weather. Waiting for next retry", e);
result.stats.numIoExceptions++;
}
}
@Nullable
@RequiresPermission(anyOf = {ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION})
private Location requestLocationInfoBlocking() throws InterruptedException {
List<String> providers = mLocationManager.getAllProviders();
if (!providers.contains(LocationManager.NETWORK_PROVIDER)) return null;
if (!mLocationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) return null;
SimpleLocationListener listener = new SimpleLocationListener(mLocationManager);
mLocationManager.requestSingleUpdate(LocationManager.NETWORK_PROVIDER, listener,
Looper.getMainLooper());
Location result = listener.waitForResult();
if (result == null) {
result = mLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
}
if (BuildConfig.DEBUG) Log.d("WeatherLoadingService", "location: " + result);
return result;
}
private String[] addFallbackWoeid(String[] woeids,
SimpleArrayMap<String, String> woeidTimezones) {
PreferenceHelper preferenceHelper = mPreferenceHelper;
String woeid = preferenceHelper.getDefaultLocationWoeId();
if (woeid != null) {
String[] tmp = new String[woeids.length + 1];
System.arraycopy(woeids, 0, tmp, 1, woeids.length);
tmp[0] = woeid;
woeids = tmp;
String woeidTimezone = preferenceHelper.getDefaultLocationTimezone();
if (!woeidTimezones.containsKey(woeid)) {
woeidTimezones.put(woeid, woeidTimezone);
}
}
return woeids;
}
private void syncImages(SyncResult result, ConnectivityManager cm,
PreferenceHelper preferenceHelper, SimpleArrayMap<String, String> woeidTimezones,
SimpleArrayMap<String, WeatherData> previousData, WeatherData weatherData) throws VolleyError {
NetworkInfo netInfo;
String woeid = weatherData.woeid;
WeatherData previous = previousData.get(woeid);
File[] photos = mWeatherUtils.getCityPhotos(mContext, woeid);
boolean changed = photos == null || previous == null ||
previous.nowConditionCode != weatherData.nowConditionCode;
if (changed) {
boolean downloadEnabled = preferenceHelper.getUseFlickrImages();
boolean downloadOnWifiOnly = preferenceHelper.getDownloadImagesOnWifiOnly();
boolean downloadWhenRoaming = preferenceHelper.getDownloadWhenRoaming();
netInfo = cm.getActiveNetworkInfo();
if (netInfo == null) return;
boolean wifi = netInfo.getType() == ConnectivityManager.TYPE_WIFI;
boolean roaming = netInfo.isRoaming();
// tell the sync we got an io exception
// so it knows we would like to try again later
if (roaming && !downloadWhenRoaming) {
result.stats.numIoExceptions++;
return;
}
// well, we are not on wifi, and the user
// only wants to sync this when wifi
// is enabled.
if (!wifi && downloadOnWifiOnly) {
result.stats.numIoExceptions++;
return;
}
// only download when the user has the option
// to download flickr images enabled in
// settings
if (downloadEnabled) {
String timezone = woeidTimezones.get(woeid);
downloadWeatherImages(mContext, mBitmapUtils, woeid, weatherData, timezone);
result.stats.numInserts++;
}
}
}
void onWeatherDataLoaded(@Nullable CircularArray<WeatherData> weatherDataList, String unit) {
int N = weatherDataList == null ? 0 : weatherDataList.size();
for (int i1 = 0; i1 < N; i1++) {
WeatherData weatherData = weatherDataList.get(i1);
ContentValues weatherValues = new ContentValues();
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_LAST_UPDATED,
System.currentTimeMillis());
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_WOEID, weatherData.woeid);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_ATMOSPHERE_HUMIDITY,
weatherData.atmosphereHumidity);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_ATMOSPHERE_PRESSURE,
weatherData.atmospherePressure);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_ATMOSPHERE_RISING,
weatherData.atmosphereRising);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_ATMOSPHERE_VISIBILITY,
weatherData.atmosphereVisible);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_NOW_CONDITION_CODE,
weatherData.nowConditionCode);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_NOW_TEMPERATURE,
weatherData.nowTemperature);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_SUNRISE,
weatherData.sunrise);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_SUNSET,
weatherData.sunset);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_WIND_CHILL,
weatherData.windChill);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_WIND_DIRECTION,
weatherData.windDirection);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_WIND_SPEED,
weatherData.windSpeed);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_CITY,
weatherData.location);
weatherValues.put(WeatherContract.WeatherColumns.COLUMN_NAME_UNIT,
unit);
ContentResolver contentResolver = mContext.getContentResolver();
contentResolver.insert(WeatherContract.WeatherColumns.CONTENT_URI, weatherValues);
List<WeatherData.Forecast> forecasts = weatherData.forecasts;
if (!forecasts.isEmpty()) {
int count = forecasts.size();
ContentValues[] forecastValues = new ContentValues[count];
for (int i = 0; i < count; i++) {
WeatherData.Forecast forecast = forecasts.get(i);
ContentValues contentValues = new ContentValues();
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_FORECAST_DAY,
forecast.julianDay);
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_LAST_UPDATED,
System.currentTimeMillis());
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_LOCATION_WOEID,
weatherData.woeid);
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_CONDITION_CODE,
forecast.conditionCode);
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_TEMPERATURE_HIGH,
forecast.high);
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_TEMPERATURE_LOW,
forecast.low);
contentValues.put(WeatherContract.ForecastColumns.COLUMN_NAME_UNIT,
unit);
forecastValues[i] = contentValues;
}
Uri uri = WeatherContract.ForecastColumns.CONTENT_URI;
contentResolver.bulkInsert(uri, forecastValues);
}
}
Intent i = new Intent(ACTION_WEATHER_UPDATED);
i.setPackage(mContext.getPackageName());
mContext.sendBroadcast(i);
}
private class SimpleLocationListener implements LocationListener {
final CountDownLatch mCountDownLatch = new CountDownLatch(1);
final AtomicReference<Location> mLocationResult = new AtomicReference<>();
private final LocationManager mLocationManager;
public SimpleLocationListener(LocationManager locationManager) {
mLocationManager = locationManager;
}
@Override
public void onLocationChanged(final Location location) {
mLocationManager.removeUpdates(this);
mLocationResult.set(location);
mCountDownLatch.countDown();
}
@Override
public void onStatusChanged(String s, int i, Bundle bundle) {
}
@Override
public void onProviderEnabled(String s) {
}
@Override
public void onProviderDisabled(String s) {
}
public Location waitForResult() throws InterruptedException {
mCountDownLatch.await(5, TimeUnit.SECONDS);
return mLocationResult.get();
}
}
private class WeatherDataLoader {
final String[] mWoeids;
final String mUnit;
@Nullable
private final Location mLocation;
public WeatherDataLoader(@Nullable Location location, String[] woeids, String unit) {
mLocation = location;
mWoeids = woeids;
mUnit = unit;
}
public CircularArray<WeatherData> queryWeather() throws CantGetWeatherException {
YahooWeatherApiClient.LocationInfo locationInfo = mLocation == null ?
null : YahooWeatherApiClient.getLocationInfo(mLocation);
// get the woeids from the list
String currentWoeid = saveAndGetCurrentWoeid(locationInfo);
CircularArray<String> woeidsToLoad = new CircularArray<>();
if (currentWoeid != null) {
woeidsToLoad.addLast(currentWoeid);
}
for (String woeid : mWoeids) {
if (!ArrayUtils.contains(woeidsToLoad, woeid)) {
woeidsToLoad.addLast(woeid);
}
}
CircularArray<WeatherData> data =
YahooWeatherApiClient.getWeatherForWoeids(woeidsToLoad, mUnit);
processResult(data);
return data;
}
private String saveAndGetCurrentWoeid(YahooWeatherApiClient.LocationInfo locationInfo) {
if (locationInfo == null) {
return null;
}
CircularArray<String> currentWoeids = locationInfo.woeids;
if (!currentWoeids.isEmpty()) {
String currentWoeid = currentWoeids.get(0);
if (BuildConfig.DEBUG) {
Log.d("WeatherLoadingService", "saving current woeid: " + currentWoeid);
}
// save this woeid in the preferences to make sure
// this is used as the latest weather info
mPreferences.edit().putString(PREFERENCE_LAST_KNOWN_WOEID, currentWoeid).apply();
return currentWoeid;
}
return null;
}
protected void processResult(CircularArray<WeatherData> weatherData) {
onWeatherDataLoaded(weatherData, mUnit);
}
}
}