/*
* Copyright (C) 2011 The original author or authors.
*
* 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.zapta.apps.maniana.widget;
import java.io.File;
import javax.annotation.Nullable;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.format.Time;
import android.view.View;
import android.widget.RemoteViews;
import com.zapta.apps.maniana.R;
import com.zapta.apps.maniana.annotations.ApplicationScope;
import com.zapta.apps.maniana.main.MainActivity;
import com.zapta.apps.maniana.main.MyApp;
import com.zapta.apps.maniana.main.MainActivityResumeAction;
import com.zapta.apps.maniana.model.AppModel;
import com.zapta.apps.maniana.services.MainActivityServices;
import com.zapta.apps.maniana.settings.ItemFontVariation;
import com.zapta.apps.maniana.settings.PreferencesReader;
import com.zapta.apps.maniana.util.CalendarUtil;
import com.zapta.apps.maniana.util.ColorUtil;
import com.zapta.apps.maniana.util.LogUtil;
import com.zapta.apps.maniana.util.Orientation;
import com.zapta.apps.maniana.widget.ListWidgetSize.OrientationInfo;
/**
* Base class for the task list widgets.
* <p>
* The code below went though several iterations to make it functional, efficient and to overcome
* the limitations of RemoteViews (e.g. non support of custom fonts like Vavont). I will try to
* outline here the overall design as well as non obvious considerations. If you change this code or
* adapt it to other applications make sure to throughly test it with different Android versions
* screen sizes and orientations.
* <p>
* Main features: 1. Supports custom fonts (not supported directly by Remote Views). 2. Single code
* and layout supports multiple widget sizes and both orientation. 3. Automatic and smooth
* orientation change when home launcher changes orientation. 4. Uses efficiently a static bitmap
* background (paper). 5. Multiple 'hot areas' on the widget that dispatch intents.
* <p>
* The main layout of this widget is widget_list_layout.xml. It is used for all supported widget
* sizes (currently 5 of them) and both orientation. The layout contains these parts 1. A place to
* set the static background image (paper). Note: this bitmap could be included in the image bitmap
* (part 2 below) but this increased the size of the dynamic bitmap files and slow the widget
* update. 2. A place to show two bitmap images, from local file URI, for portrait and landscape
* views of each of the 5 widget size (total of 2 x 5 images). The visibility of the images in each
* pair are controlled automatically by a style that enables one in portrait mode and the other in
* landscape mode. Further, the widget code, when it set a widget RemoteViews for a widget of a
* certain size, make sure to disable (visibility = GONE) all the images of the other sizes. 3. Hot
* areas that can be set to trigger intents. These areas overlay the buttons of the widget which are
* part of the bitmap images (part 2 above).
* <p>
* Once the remote views is setup for a given widget instance, the orientation change in the home
* launcher result in smooth widget orientation change as the widget contain recreate a new widget
* layout with the current landscape/portrait style and applies to it the commands recorded in the
* RemoteViews.
* <p>
* When a widget of a given size is updated, the update method below creates two bitmap .png files
* whose name encode the widget size and the orientation. Then the RemoteViews is set such that the
* respective two ImageViews in the main layout are set with URI to the respective files.
* <p>
* The two files are rendered from a template layout that includes the widget toolbar and text. The
* layout is populated and then rendered into two bitmaps with size for landscape and portrait
* orientation respectively. These bitmaps are then save to local files, overwriting previous files
* for this widget size. Note that the template layout is inflated locally and not via a
* RemoteViews.
* <p>
* What did not work? 1. Passing the bitmap to the remote views via setImageViewBitmap(). For large
* widget the bitmap was too big and once in a while Android just dropped it. 2. Passing the bitmap
* to the remote views via multiple 'slices' of setImageViewBitmap and ImageView. Same problem as
* above. 3. Using only a pair of ImageView and setting their size dynamically to the current widget
* size. Could not find a way to do it with RemoteViews. 4. Letting the dynamic template bitmap file
* to set the size of the containing ImageView. By default, the ImageView scaled the fetched bitmap
* file by density(). Pre scaling the bitmap by this factor to compensate for the down scaling works
* but introduces font artifcats due to the two scaling operations. 5. Using a single paper
* background resource. When applying a background bitmap resource to a view, Android can stretch it
* to fit the view size but does not shrink it, instead it stretch the view to match the background
* image. As a result, background image must be LE the view size. Using a single background bitmap
* that is smaller than the smaller widget size will result in poor quality when used with larger
* widgets due to the low resolution. For this reason, we select dynamically one out of 4 or so
* background image resources of different size. This way we can have the best match. Note that
* sizes are are measures in actual pixels, not DIP, so a 4x3 widget for example can have different
* sizes based on each device's density.
*
* @author Tal Dayan
*/
@ApplicationScope
public abstract class ListWidgetProvider extends BaseWidgetProvider {
/** Used to avoid too frequent file garbage collection. */
private static long gLastGarbageCollectionTimeMillis = 0;
public ListWidgetProvider() {
}
protected abstract ListWidgetSize listWidgetSize();
/**
* Called by the widget host. Updates one or more widgets of the same size.
*/
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
final Time timeNow = new Time();
timeNow.setToNow();
update(context, appWidgetManager, listWidgetSize(), appWidgetIds,
loadModelForWidgets(context, timeNow), timeNow);
}
/**
* Internal widget update method that accepts the model as a parameter. Updates one or more
* widgets of the same size.
*
* @param context the widget context
* @param appWidgetManager appWidgetManager to use.
* @param listWidgetSize the size of the updated widget instance
* @param appWidgetIds a list of widget instance ids to update.
* @param model the Maniana app model instance with the data to render. If null, the widget will
* display an error message.
*/
private static final void update(Context context, AppWidgetManager appWidgetManager,
ListWidgetSize listWidgetSize, int[] appWidgetIds, @Nullable AppModel model,
Time sometimeToday) {
if (appWidgetIds.length == 0) {
return;
}
final MyApp app = (MyApp) context.getApplicationContext();
final PreferencesReader prefReader = app.preferencesReader();
final boolean paper = prefReader.getWidgetBackgroundPaperPreference();
final int templateBackgroundColor = templateBackgroundColor(prefReader, paper);
final ItemFontVariation fontVariation = ItemFontVariation.newFromWidgetPreferences(context,
prefReader);
final boolean toolbarEanbled = prefReader.getWidgetShowToolbarPreference();
final boolean showDate = toolbarEanbled && prefReader.getWidgetShowDatePreference();
final boolean titleClickLaunchesCalendar = showDate
&& prefReader.getCalendarLaunchPreference();
final boolean includeCompletedItems = prefReader.getWidgetShowCompletedItemsPreference();
final boolean singleLine = prefReader.getWidgetSingleLinePreference();
final boolean autoFit = prefReader.getWidgetAutoFitPreference();
// NOTE: we use a template layout that is rendered to a bitmap rather rendering directly
// a remote view. This allows us to use custom fonts which are not supported by
// remote view. This also increase the complexity and makes the widget more sensitive
// to resizing.
final ListWidgetProviderTemplate template = new ListWidgetProviderTemplate(context, model,
sometimeToday, paper, templateBackgroundColor, toolbarEanbled, showDate,
includeCompletedItems, singleLine, fontVariation, autoFit);
// Create the widget remote view
final RemoteViews remoteViews = new RemoteViews(context.getPackageName(),
R.layout.widget_list_layout);
setOnClickLaunchMainActivity(context, remoteViews, R.id.widget_list_bitmaps,
MainActivityResumeAction.SHOW_TODAY_PAGE);
setRemoteViewsToolbar(context, remoteViews, toolbarEanbled, titleClickLaunchesCalendar);
renderOneOrientation(context, remoteViews, template, listWidgetSize, Orientation.PORTRAIT,
paper);
renderOneOrientation(context, remoteViews, template, listWidgetSize, Orientation.LANDSCAPE,
paper);
// Flush the remote view
appWidgetManager.updateAppWidget(appWidgetIds, remoteViews);
}
/** Compute the template background color. */
private static int templateBackgroundColor(final PreferencesReader prefReader,
final boolean backgroundPaper) {
if (!backgroundPaper) {
return prefReader.getWidgetBackgroundColorPreference();
}
// Using paper background.
return ColorUtil.mapPaperColorPrefernce(prefReader.getWidgetPaperColorPreference());
}
/** Set the image of a single orientation. */
private static final void renderOneOrientation(Context context, RemoteViews remoteViews,
ListWidgetProviderTemplate template, ListWidgetSize listWidgetSize,
Orientation orientation, boolean backgroundPaper) {
final OrientationInfo orientationInfo = orientation.isPortrait ? listWidgetSize.portraitInfo
: listWidgetSize.landscapeInfo;
final int widgetWidthPixels = (int) context.getResources().getDimensionPixelSize(
orientationInfo.widthDipResourceId);
final int widgetHeightPixels = (int) context.getResources().getDimensionPixelSize(
orientationInfo.heightDipResourceId);
@Nullable
final PaperBackground paperBackground = backgroundPaper ? PaperBackground.getBestSize(
widgetWidthPixels, widgetHeightPixels) : null;
final Uri fileUri = template.renderOrientation(listWidgetSize, orientation,
widgetWidthPixels, widgetHeightPixels, paperBackground);
// Set the bitmap images of given orientation. The bitmap of the size we currently
// process is set and the other are made GONE. Only bitmaps of the given orientation
// are touched here
for (ListWidgetSize iterListWidgetSize : ListWidgetSize.LIST_WIDGET_SIZES) {
final boolean thisSize = (iterListWidgetSize == listWidgetSize);
final OrientationInfo iterOrientationInfo = orientation.isPortrait ? iterListWidgetSize.portraitInfo
: iterListWidgetSize.landscapeInfo;
final int iterBitmapResource = iterOrientationInfo.imageViewId;
if (thisSize) {
// NOTE: setting up a temporary dummy image to cause the image view to reload the
// file URI in case it changed.
remoteViews.setInt(iterBitmapResource, "setImageResource", R.drawable.place_holder);
remoteViews.setUri(iterBitmapResource, "setImageURI", fileUri);
// Set paper background if used or transparent otherwise.
// TODO: will using the background solid color here rather than the template bitmap
// reduce the file size? If so, make this change.
if (backgroundPaper) {
remoteViews.setInt(iterBitmapResource, "setBackgroundResource",
paperBackground.drawableResourceId);
} else {
remoteViews.setInt(iterBitmapResource, "setBackgroundColor", 0x00000000);
}
} else {
// A different size. Disable it, regardless of orientation.
remoteViews.setInt(iterBitmapResource, "setVisibility", View.GONE);
}
}
}
/** Set/disable the toolbar click overlay in the remote views layout. */
private static final void setRemoteViewsToolbar(Context context, RemoteViews remoteViews,
boolean toolbarEnabled, boolean titleClickLaunchesCalendar) {
if (toolbarEnabled) {
// NOTE: can be null even if titleClickLaunchesCalendar is true.
@Nullable
final Intent calendarIntent = titleClickLaunchesCalendar ? CalendarUtil
.maybeConstructGoogleCalendarIntent(context) : null;
if (calendarIntent != null) {
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0,
calendarIntent, PendingIntent.FLAG_UPDATE_CURRENT);
remoteViews.setOnClickPendingIntent(R.id.widget_list_toolbar_title_overlay,
pendingIntent);
} else {
// NOTE: could not find a way to disable the on click of the title area so instead
// setting up an on click identical to the widget body.
setOnClickLaunchMainActivity(context, remoteViews, R.id.widget_list_toolbar_title_overlay,
MainActivityResumeAction.SHOW_TODAY_PAGE);
}
} else {
remoteViews.setInt(R.id.widget_list_toolbar_title_overlay, "setVisibility", View.GONE);
}
// Set or disable the click overlay of the add-item-by-text button.
if (toolbarEnabled) {
remoteViews.setInt(R.id.widget_list_toolbar_add_by_text_overlay, "setVisibility",
View.VISIBLE);
setOnClickLaunchMainActivity(context, remoteViews,
R.id.widget_list_toolbar_add_by_text_overlay,
MainActivityResumeAction.ADD_NEW_ITEM_BY_TEXT);
} else {
remoteViews.setInt(R.id.widget_list_toolbar_add_by_text_overlay, "setVisibility",
View.GONE);
}
// Set or disable the click overlay of the add-item-by-voice button.
if (toolbarEnabled && MainActivityServices.isVoiceRecognitionSupported(context)) {
remoteViews.setInt(R.id.widget_list_toolbar_add_by_voice_overlay, "setVisibility",
View.VISIBLE);
setOnClickLaunchMainActivity(context, remoteViews,
R.id.widget_list_toolbar_add_by_voice_overlay,
MainActivityResumeAction.ADD_NEW_ITEM_BY_VOICE);
} else {
remoteViews.setInt(R.id.widget_list_toolbar_add_by_voice_overlay, "setVisibility",
View.GONE);
}
}
/** Set onClick() action of given remote view element to launch the app. */
private static final void setOnClickLaunchMainActivity(Context context,
RemoteViews remoteViews, int viewId, MainActivityResumeAction resumeAction) {
final Intent intent = new Intent(context, MainActivity.class);
MainActivityResumeAction.setInIntent(intent, resumeAction);
// Setting unique intent action and using FLAG_UPDATE_CURRENT to avoid cross
// reuse of pending intents. See http://tinyurl.com/8axhrlp for more info.
intent.setAction("maniana.list_widget." + resumeAction.toString());
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT);
remoteViews.setOnClickPendingIntent(viewId, pendingIntent);
}
/**
* Update all list widgets using a given model.
*
* This method is called from the main activity when model changes need to be flushed to the
* widgets. The model is already pushed and sorted according to the currnet setting.
*
* @param context app context.
* @param model app model with task data. If null, widgets will show a warning message.
*/
public static void updateAllListWidgetsFromModel(Context context, @Nullable AppModel model,
Time sometimeToday) {
final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
for (ListWidgetSize listWidgetSize : ListWidgetSize.LIST_WIDGET_SIZES) {
final int widgetIds[] = appWidgetManager.getAppWidgetIds(new ComponentName(context,
listWidgetSize.widgetProviderClass));
// Update all widgets of this size, if any.
if (widgetIds != null) {
update(context, appWidgetManager, listWidgetSize, widgetIds, model, sometimeToday);
}
}
// Since we updated above all active widget files, it is safe to delete old ones.
garbageCollectOlderFiles(context);
}
/**
* Garbage collect old widget image files. This should be called only after all active widget
* files as been updated to make sure it does not deleted active widget files.
*/
private static void garbageCollectOlderFiles(Context context) {
final long timeNowMillis = System.currentTimeMillis();
// If we performed a garbage collection in the last hour, do nothing. We don't
// want to incure garbage collection delay on each update. If the app got destroyed
// and recreated, no big deal, we will just do one more garbage collection.
final long dtMillis = (timeNowMillis - gLastGarbageCollectionTimeMillis);
if (dtMillis >= 0 && dtMillis <= 60 * 60 * 1000) {
return;
}
// We don't bother to protect with a lock.
gLastGarbageCollectionTimeMillis = timeNowMillis;
// Track stats
int deletedFileCount = 0;
int nonRelatedFiles = 0;
int keptFileCount = 0;
final File dir = context.getFilesDir();
final String fileNames[] = dir.list();
for (String fileName : fileNames) {
// TODO: share file name const with ListWidgetSize
if (!fileName.startsWith("list_widget_image_")) {
nonRelatedFiles++;
continue;
}
final File file = new File(dir, fileName);
final long lastModifiedMillis = file.lastModified();
final long fileAgeMillis = timeNowMillis - lastModifiedMillis;
final long fileAgeMinutes = fileAgeMillis / (1000 * 60);
// We use an arbitrary age threshold of 10 minutes. Since the active widget files
// were just been updated, we could use a much shorter threshold. We are also
// deleting files that are too much in the future, in case a file happen to
// have time far in the future.
if (Math.abs(fileAgeMinutes) > 10) {
final boolean deletedOk = file.delete();
if (deletedOk) {
LogUtil.info("Garbage collected %s, %d minutes old", fileName, fileAgeMinutes);
deletedFileCount++;
} else {
LogUtil.error("Failed to delete: %s", file.getAbsoluteFile());
}
} else {
keptFileCount++;
}
}
LogUtil.debug("Garbage collected %d widget files in %dms, kept %d images + %d files.",
deletedFileCount, System.currentTimeMillis() - timeNowMillis, keptFileCount,
nonRelatedFiles);
}
}