Tag Archives: Android

How to create Calendar-like app widget with optimized images in the list

The process of app widgets creation is quite common and described in official docs very well. Google uses almost all the features from this guide in the Google Calendar that offers a few widgets with lists of scheduled events for nearest time.

Source code for this application is available in Google Git for everybody and it has the free license. But if you want to extend the functions and use your server API, you may encounter with some problems.

In this article I will try to write some code for widget with a list of images using RemoteViewsFactory. App widget will load the data about my meetings for the next week.

First of all, you should be familiar with appwidget’s Google guide.  Second, create your project and configure app widget as usual. Then let’s write some code for AppWidgetProvider:

public class AppWidgetProvider extends android.appwidget.AppWidgetProvider
{
    private static String APP_WIDGET_UPDATE = "your.package.name.MY_APPWIDGET_UPDATE";
    public static final long UPDATE_TIME_MILLIS = 15 * 60 * 1000; // 15 minutes

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int appWidgetIds[])
    {
        final int N = appWidgetIds.length;
        for (int i = 0; i < N; i++)
        {
            int appWidgetId = appWidgetIds[i];
            RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);
            appWidgetManager.updateAppWidget(appWidgetId, views);
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }

    public static void updateAppWidget(Context context,
                                       AppWidgetManager appWidgetManager,
                                       int appWidgetId)
    {
        Intent updateIntent = new Intent(context, AppWidgetRefreshService.class);
        updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
        updateIntent.setData(Uri.parse(updateIntent.toUri(Intent.URI_INTENT_SCHEME)));
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget);

        // Calendar header
        Calendar calendar = Calendar.getInstance();
        //long millis = calendar.getTimeInMillis();
        final String dayOfWeek = calendar.getDisplayName(Calendar.DAY_OF_WEEK,
                Calendar.LONG, Locale.getDefault());
        final String date = Utils.getReadableDate(calendar.getTime());
        views.setTextViewText(R.id.day_of_week, dayOfWeek);
        views.setTextViewText(R.id.date, date);
        // Attach to list of events
        views.setRemoteAdapter(R.id.events_list, updateIntent);
        appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.events_list);

        // Launch calendar app when the user taps on the header
        final Intent launchServiceIntent = new Intent(Intent.ACTION_VIEW);
        launchServiceIntent.setClass(context, MainActivity.class);
        final PendingIntent launchCalendarPendingIntent = PendingIntent.getActivity(
                context, 0 /* no requestCode */, launchServiceIntent, 0 /* no flags */);
        views.setOnClickPendingIntent(R.id.header, launchCalendarPendingIntent);

        // Each list item will call setOnClickExtra() to let the list know
        // which item
        // is selected by a user.
        final PendingIntent updateEventIntent = getLaunchPendingIntentTemplate(context);
        views.setPendingIntentTemplate(R.id.events_list, updateEventIntent);
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }

    /**
     * Build a {@link PendingIntent} to launch the Calendar app. This should be used
     * in combination with {@link RemoteViews#setPendingIntentTemplate(int, PendingIntent)}.
     */
    public static PendingIntent getLaunchPendingIntentTemplate(Context context)
    {
        Intent launchIntent = new Intent();
        launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
        launchIntent.setData(Uri.parse(launchIntent.toUri(Intent.URI_INTENT_SCHEME)));
        launchIntent.setClass(context, OnItemClickActivity.class);
        return PendingIntent.getActivity(context, 0 , launchIntent,
                PendingIntent.FLAG_UPDATE_CURRENT);
    }

    private static PendingIntent createClockTickIntent(Context context)
    {
        Intent intent = new Intent(APP_WIDGET_UPDATE);
        PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
                intent, PendingIntent.FLAG_UPDATE_CURRENT);
        return pendingIntent;
    }

    public static void startAppWidgetUpdater(Context context)
    {
        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        alarmManager.setRepeating(AlarmManager.RTC,
                System.currentTimeMillis(),// start with now
                UPDATE_TIME_MILLIS,
                createClockTickIntent(context));
    }

    public static boolean isUpdaterStarted(Context context)
    {
        return (PendingIntent.getBroadcast(context, 0, new Intent(APP_WIDGET_UPDATE),
                PendingIntent.FLAG_NO_CREATE) != null);
    }

    @Override
    public void onEnabled(Context context)
    {
        startAppWidgetUpdater(context);

        super.onEnabled(context);
    }

    @Override
    public void onDisabled(Context context)
    {
        AlarmManager alarmManager = (AlarmManager) context
                .getSystemService(Context.ALARM_SERVICE);
        alarmManager.cancel(createClockTickIntent(context));

        super.onDisabled(context);
    }

    @Override
    public void onReceive(Context context, Intent intent)
    {
        if (APP_WIDGET_UPDATE.equals(intent.getAction()))
        {
            // Get the widget manager and ids for this widget provider, then
            // call the shared
            // clock update method.
            ComponentName thisAppWidget = getComponentName(context);
            AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
            int ids[] = appWidgetManager.getAppWidgetIds(thisAppWidget);
            for (int appWidgetID : ids)
            {
                updateAppWidget(context, appWidgetManager, appWidgetID);
            }
        }
        else
            super.onReceive(context, intent);
    }

    /**
     * Build {@link ComponentName} describing this specific
     * {@link android.appwidget.AppWidgetProvider}
     */
    public static ComponentName getComponentName(Context context)
    {
        return new ComponentName(context, AppWidgetProvider.class);
    }
}

Android does not allow us to use widget update time less than 60 minutes. So we use AlarmManager to refresh app widget with the period we need. AlarmManager helps to save the battery and in some cases can avoid unnecessary call of expensive app widget refresh methods.

Note: always set the update frequency as less as you can!

This code creates header for widget. The header contains today’s day of week, date and starts MainActivity on click. Also we specified PendingIntent template that we will use for launching OnItemClickActivity when user touches list item.

appwidget.xml:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:focusable="true"
    android:clickable="true"
    >

    <!-- Header -->
    <LinearLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="56dip"
        android:gravity="center_vertical|start"
        android:orientation="vertical"
        android:background="@drawable/widget_header_shape">

        <TextView
            android:id="@+id/day_of_week"
            android:paddingLeft="8dip"
            android:paddingRight="8dip"
            android:paddingTop="2dip"
            android:paddingBottom="2dip"
            android:textColor="@color/appwidget_week"
            android:shadowColor="#0dffffff"
            android:shadowRadius="3"
            style="@style/WidgetDayOfWeekStyle" />

        <TextView
            android:id="@+id/date"
            android:paddingLeft="8dip"
            android:paddingRight="8dip"
            android:paddingTop="2dip"
            android:paddingBottom="2dip"
            android:textColor="@color/appwidget_month"
            style="@style/WidgetDateStyle"/>
    </LinearLayout>

    <!-- Event list -->
    <ListView
        android:id="@+id/events_list"
        android:layout_width="match_parent"
        android:layout_height="0dip"
        android:layout_weight="1"
        android:divider="@null"
        android:listSelector="@android:color/transparent"
        android:cacheColorHint="@null"
        android:background="@android:color/white"/>
</LinearLayout>

Add some code to AndroidManifest.xml:

<receiver
    android:name=".appwidget.AppWidgetProvider"
    android:label="@string/appwidget_name_large"
    android:enabled="true">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        <action android:name="your.package.name.MY_APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/appwidget_info" />
</receiver>

<service
    android:name=".appwidget.AppWidgetRefreshService"
    android:permission="android.permission.BIND_REMOTEVIEWS"
    android:exported="false"/>

<receiver android:name=".appwidget.AppWidgetRefreshService$CalendarFactory"
          android:permission="android.permission.BIND_REMOTEVIEWS">
    <intent-filter>
        <action android:name="your.package.name.MY_APPWIDGET_UPDATE" />
    </intent-filter>
</receiver>

Here we have one additional receiver for custom intent your.package.name.MY_APPWIDGET_UPDATE that we use for fire from AlarmManager.

Now all components are ready for custom RemoteViewsService that will contain a list of scheduled meetings from server.

public class AppWidgetRefreshService extends RemoteViewsService
{
    private static final String TAG = "MY_APPWIDGET_UPDATE";
    @Override
    public RemoteViewsFactory onGetViewFactory(Intent intent)
    {
        return new CalendarFactory(getApplicationContext(), intent);
    }

    public static class CalendarFactory extends BroadcastReceiver implements
            RemoteViewsService.RemoteViewsFactory
    {
        private Context mContext;
        private Resources mResources;
        private int mAppWidgetId;
        private int mStandardColor;
        private int mFadedColor;

        private static List<Event> queryResults = new ArrayList<>();

        private LruCache<String, Bitmap> bitmapLruCache;

        public CalendarFactory() {
            // This is being created as part of onReceive
        }

        protected CalendarFactory(Context context, Intent intent)
        {
            mContext = context;
            mResources = context.getResources();
            mAppWidgetId = intent.getIntExtra(
                    AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);

            mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
            mFadedColor = mResources.getColor(R.color.appwidget_item_declined_color);
            initializeCache();
        }

        protected void initializeCache()
        {
            final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

            // Use 1/10th of the available memory for this memory cache.
            final int cacheSize = maxMemory / 10;

            bitmapLruCache = new LruCache<String, Bitmap>(cacheSize)
            {
                @Override
                protected int sizeOf(String key, Bitmap bitmap) {
                    // The cache size will be measured in kilobytes rather than
                    // number of items.
                    return bitmap.getByteCount() / 1024;
                }
            };
        }
        @Override
        public void onCreate()
        {
            loadData();
        }

        @Override
        public void onDataSetChanged()
        {
            Log.d(TAG, "onDataSetChanged");
        }

        @Override
        public void onDestroy()
        {
        }

        @Override
        public RemoteViews getLoadingView()
        {
            RemoteViews views = new RemoteViews(mContext.getPackageName(),
                    R.layout.appwidget_loading);
            return views;
        }

        @Override
        public RemoteViews getViewAt(int position)
        {
            // we use getCount here so that it doesn't return null when empty
            if (position < 0 || position >= getCount()) return null;

            if (queryResults == null)
            {
                return new RemoteViews(mContext.getPackageName(),
                        R.layout.appwidget_loading);
            }
            if (queryResults.isEmpty())
            {
                return new RemoteViews(mContext.getPackageName(),
                        R.layout.appwidget_no_events);
            }

            final RemoteViews views;
            views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);

            final Event event = queryResults.get(position);
            if(event == null) return views;

            if(position == 0)
                if(!Utils.dateOfSameDay(event.getDate(), new Date()))
                    updateTextView(views, R.id.date,
                            View.VISIBLE, Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);
            else
            {
                Event prevEvent = queryResults.get(position - 1);

                if(!Utils.dateOfSameDay(event.getDate(), prevEvent.getDate()))
                    updateTextView(views, R.id.date, View.VISIBLE,
                            Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);
            }

            if (event.getState() == Event.EventState.waiting)
                views.setInt(R.id.widget_row, "setBackgroundColor",
                            mContext.getResources().getColor(event.isApprovedByMe()
                                    ? R.color.event_state_bg_yellow
                                    : R.color.event_state_bg_red));
            else
                views.setInt(R.id.widget_row, "setBackgroundResource",
                        R.drawable.agenda_item_bg_primary);

            updateTextView(views, R.id.when,
                    View.VISIBLE,
                    Utils.getReadableTime(event.getStartHour()));

            updateTextView(views, R.id.title, View.VISIBLE, event.getTitle());
            updateTextView(views, R.id.who, View.VISIBLE, event.getName());

            updateImageView(views, R.id.avatar, event.getAvatar());

            views.setInt(R.id.who, "setTextColor", mStandardColor);
            views.setInt(R.id.when, "setTextColor", mStandardColor);
            views.setInt(R.id.title, "setTextColor", mFadedColor);

            final Intent eventViewIntent = getLaunchFillInIntent(
                    mContext, event.getEventId());
            views.setOnClickFillInIntent(R.id.widget_row, eventViewIntent);
            return views;
        }

        @Override
        public int getViewTypeCount() {
            return 3;
        }

        @Override
        public int getCount()
        {
            // if there are no events, we still return 1 to represent the "no
            // events" view
            if (queryResults == null)
                return 1;

            return Math.max(1, queryResults.size());
        }

        @Override
        public long getItemId(int position)
        {
            if(queryResults == null || queryResults.isEmpty()) return 0;
            return queryResults.get(position).getEventId();//result;
        }

        @Override
        public boolean hasStableIds() {
            return true;
        }

        /**
         * Load events from server
         */
        public void loadData()
        {
            //fill the queryResults
        }

        void updateTextView(RemoteViews views, int id, int visibility, String string) {
            views.setViewVisibility(id, visibility);
            if (visibility == View.VISIBLE) {
                views.setTextViewText(id, string);
            }
        }

        void updateImageView(RemoteViews views, int resourceId, final String imageUrl)
        {
            Bitmap bitmap = bitmapLruCache.get(imageUrl);

            if (bitmap == null)
               bitmapLruCache.put(imageUrl, bitmap);
            views.setImageViewBitmap(resourceId, bitmap);
        }

        @Override
        public void onReceive(Context context, Intent intent)
        {
            mContext = context;
            loadData();
        }
    }
    /**
     * Build an {@link Intent} available to launch the Calendar app.
     * This should be used in combination with
     * If the go to time is 0, then calendar will be launched without a starting time.
     *
     */
    static Intent getLaunchFillInIntent(Context context, int id)
    {
        Intent eventViewIntent = new Intent();

        if (id != 0)
        {
            eventViewIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
                    Intent.FLAG_ACTIVITY_TASK_ON_HOME);
            eventViewIntent.setClass(context, OnItemClickActivity.class);
        }
        else
        {
            // If we do not have an event id - start MainActivity
            eventViewIntent.setClass(context, MainActivity.class);
        }

        return eventViewIntent;
    }
}

First important thing in this code is caching drawables with LruCache. This approach helps us to fix weird issue with memory leak in list with images.

Second, in getViewAt method I realize grouping by the day in quite unusual manner:

            if(position == 0)
                if(!Utils.dateOfSameDay(event.getDate(), new Date()))
                    updateTextView(views, R.id.date,
                            View.VISIBLE, Utils.getReadableDate(event.getDate()));
                else
                    views.setViewVisibility(R.id.date, View.GONE);

I think when you need just a divider between days without actual grouping them (for collapse, for example), this solution is quite quick and beautiful (but follows to some excessive TextViews on the other hand).

widget_item layout:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/content"
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:minHeight="72dp"
    android:background="@drawable/bg_event_cal_widget_holo">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:id="@+id/date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:maxLines="1"
            android:textSize="14sp"
            android:ellipsize="marquee"
            android:textColor="@color/appwidget_item_declined_color"
            style="?android:attr/textAppearanceMediumInverse"
            android:visibility="gone"
            android:paddingLeft="8dp"
            android:paddingRight="8dp"
            android:paddingTop="5dp"
            android:paddingBottom="8dp"
            android:gravity="center_vertical"/>

        <LinearLayout
            android:id="@+id/widget_row"
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:paddingBottom="4dp"
            android:paddingTop="4dp"
            android:orientation="horizontal"
            android:minHeight="72dp" >

            <ImageView
                android:id="@+id/avatar"
                android:layout_marginLeft="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginRight="16dp"
                android:layout_marginEnd="16dp"
                android:layout_width="40dip"
                android:layout_height="40dip"
                android:scaleType="fitXY"
                android:layout_gravity="center_vertical"
                android:src="@drawable/ic_launcher"/>

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_marginRight="16dip"
                android:orientation="vertical">

                <TextView
                    android:id="@+id/who"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:maxLines="2"
                    android:textSize="14sp"
                    android:textStyle="bold"
                    android:ellipsize="marquee"
                    android:textColor="@color/appwidget_title"
                    style="?android:attr/textAppearanceMediumInverse" />

                <TextView
                    android:id="@+id/when"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:singleLine="true"
                    android:textSize="12sp"
                    android:ellipsize="marquee"
                    android:textColor="@color/appwidget_when"
                    style="?android:attr/textAppearanceSmallInverse" />

                <TextView
                    android:id="@+id/title"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:singleLine="true"
                    android:ellipsize="marquee"
                    android:paddingBottom="2dip"
                    android:textSize="12sp"
                    android:textColor="@color/appwidget_where"
                    style="?android:attr/textAppearanceSmallInverse" />
            </LinearLayout>
        </LinearLayout>
    </LinearLayout>
</FrameLayout>

Third, I intentionally keep the code that use my Event class fields with aim of showing you how to change some TextView and ImageView properties in RemoteViews. I believe the sense of this code is clear.

The result is:

Screenshot_2015-10-23-11-52-50

How to get the REAL device time with wrong time zone

The casual approach to time management in Android device is to turn on “Automatic date & time” and “Automatic time zone” in Settings – Date & Time. But what if your user who live in Helsinki (GMT+3) will move to Beijing(GMT+8), unsets both checkboxes, leaves Helsinki’s time zone intact and just adds 5 hours to his current time?

“It’s not a problem you say and will be wrong. When your app will try to get current time using System.currentTimeMillis or Calendar.getInstance().getTimeInMillis you will get 13.00 GMT but not 18.00 GMT as you expected. All the system time functions respect the current device time zone and there is no way to get right global timestamp with wrong time zone.
So where is the solution?
  1. you may get time from Internet
  2. you may use Google Timezone API on client or server side, but the app need to know its location
  3. you can delegate all time routine to server, that will recognize client’s time zone by ip (see Ruby or PHP realizations for example).

But in spite of it all, there are many cases (offline or standalone applications, geodata absence) when the only solution is to ask people to activate time and time zone synchronization.

Javadoc with UML diagrams for Android project using (almost) only Gradle!

One of the important moments of documenting source code is creation UML diagrams. UML diagrams – a simple, unified and at the same time powerful means of description your of project from different sides.

Well, the project have written and commented in javadoc manner, how can you create and insert charts in documentation? You need a software, that makes reverse-engineering work for you. In Android case, actually, your choice is not so great: there are about five free solutions for convert your source code in javadoc with UML diagrams, but they are quite mighty.

In this post I show you how to use UMLGraph with Gradle to build docs from console.

  • first of all, you should download and install Graphviz. Graphviz is open source graph visualization software that process the output of UML doclet to png image diagram.
  • open build.gradle of your app module and add this code:
    allprojects {
    configurations {
        umljavadoc
    }

    dependencies {
        umljavadoc 'org.umlgraph:umlgraph:5.6'
    }

    //
    // While javadoc is not typically dependent on compilation, the compile steps
    // sometimes generate some sources that we wish to have in the Javadoc.
    //
    task javadoc(overwrite: true, dependsOn: build) {
        setDescription('Generates Javadoc API documentation with UMLGraph diagrams')
        setGroup(JavaBasePlugin.DOCUMENTATION_GROUP)

        doLast {
            def javaFilePath = file('src/main/java')
            if (javaFilePath.exists())
            {
                ant.javadoc(classpath: "{path_to_android_sdk}/android.jar",
                        sourcepath: file('src/main/java'),
                        packagenames: '*',
                        destdir: "{path_to_project}/javadoc",
                        private: 'true',
                        docletpath: configurations.umljavadoc.asPath,
                        charset: 'UTF-8',
                        encoding: 'UTF-8',
                        docencoding: 'UTF-8') {
                    doclet(name: 'org.umlgraph.doclet.UmlGraphDoc')
                            {
                                param(name: '-nodefontsize', value: '9')
                                param(name: '-nodefontpackagesize', value: '7')
                                param(name: '-qualify')
                                param(name: '-postfixpackage')
                                param(name: '-hide', value: 'java.*')
                                param(name: '-collpackages', value: 'java.util.*')
                                param(name: '-inferrel')
                                param(name: '-inferdep')
                                param(name: '-link', value: 'http://java.sun.com/j2se/1.5.0/docs/guide/javadoc/doclet/spec')
                                param(name: '-link', value: 'http://java.sun.com/j2se/1.5/docs/api')
                            }
                }
            }
            else
            {
                print("!!! Cannot find source path !!!");
            }
        }
    }
}
  • Specify your own classpath, destdir and other params you need.
  • open console and in project’s dir type gradlew javadoc.
  • That’s all! There is documentation with UML class diagrams in {path_to_project}/javadoc!