This widget is a joke

Aviad H
9 min readMay 19, 2021

In this tutorial I will help you to create an android jokes widget from scratch, using a real jokes API from “official jokes API” https://github.com/15Dkatz/official_joke_api.

We will cover the basics for creating a widget with a configuration screen and we will save our user selections using shared preferences.

We will also build a simple HTTP client with google volley for fetching the jokes for the widget and make it responsive to user interaction.

You can clone the project from GitHub and follow the steps below along with the coding samples.

GitHub project — https://github.com/aviadh314/JokesWidget/tree/master

Topics

*I’m using android studio, Java (8) for android.

With no further introduction, Let’s begin.

Creating a default widget with android studio.

  1. Create a new project with no activities

2. Insert “JokesWidget” for the Project name, and “com.my.jokeswidget for the ”package name

3. Right-click on app/res: new → widget → app widget

Insert “MyWidget” for the Class Name and select the settings as shown in the image below

Configuration screen layout

1. Open app/res/my_widget_configure.xml, we will add two buttons for setting the widget background, dark or light.

my_widget_configure.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="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:padding="16dp">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="20sp"
android:layout_marginBottom="8dp"
android:text="@string/configure" />
<TableRow
android:layout_height="40dp"
android:layout_width="match_parent"
android:layout_gravity="center"
android:orientation="horizontal">
<Button
android:id="@+id/btDarkMode"
android:theme="@style/Theme.AppCompat"
android:background="@drawable/rs_dark"
android:layout_height="match_parent"
android:text="@string/dark"
android:textColor="@color/white"
android:gravity="center"
android:layout_weight="1"
android:layout_margin="2dp"/>
<Button
android:id="@+id/btLightMode"
android:theme="@style/Theme.AppCompat"
android:background="@drawable/rs_light"
android:layout_height="match_parent"
android:text="@string/light"
android:textColor="@color/black"
android:layout_weight="1"
android:gravity="center"
android:layout_margin="2dp"/>
</TableRow>

<Button
android:id="@+id/btAddWidget"
android:theme="@style/Theme.AppCompat.Light"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="20sp"
android:layout_marginTop="8dp"
android:text="@string/add_widget" />
</LinearLayout>

2. Create background drawable rs_dark and rs_light

rs_dark.xml (app/res/drawable)

<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#311331" />
<stroke
android:width="1dp"
android:color="#20FFFFFF" />
<corners android:radius="20dp" />
<padding
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
android:top="5dp" />
</shape>

rs_light.xml (app/res/drawable)

<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#60DF59F3" />
<stroke
android:width="1dp"
android:color="#20FFFFFF" />
<corners android:radius="20dp" />
<padding
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
android:top="5dp" />
</shape>

Configuration screen - handle user selections

  • 1. Handle user selections and save them locally with shared preferences

MyWidgetConfigureActivity.java

package com.my.jokeswidget;

import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

import androidx.core.content.ContextCompat;

public class MyWidgetConfigureActivity extends Activity {

private static final String PREFS_NAME = "com.my.jokeswidget.MyWidget";
private static final String PREF_PREFIX_KEY = "appwidget_";

int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
Button btDarkMode, btLightMode, btAddWidget;
int widgetMode = R.drawable.rs_dark;

View.OnClickListener darkClickListener = view -> {
widgetMode = R.drawable.rs_dark;
btDarkMode.setForeground(new ColorDrawable(ContextCompat.getColor(getBaseContext(), R.color.trans_yellow)));
btLightMode.setForeground(new ColorDrawable(Color.TRANSPARENT));
};

View.OnClickListener lightClickListener = view -> {
widgetMode = R.drawable.rs_light;
btLightMode.setForeground(new ColorDrawable(ContextCompat.getColor(getBaseContext(), R.color.trans_yellow)));
btDarkMode.setForeground(new ColorDrawable(Color.TRANSPARENT));
};

View.OnClickListener addWidgetClickListener = v -> {
final Context context = MyWidgetConfigureActivity.this;

// When the button is clicked, store the background locally
saveTitlePref(context, mAppWidgetId, widgetMode);

// It is the responsibility of the configuration activity to update the app widget
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
MyWidget.updateAppWidget(context, appWidgetManager, mAppWidgetId);

// Make sure we pass back the original appWidgetId
Intent resultValue = new Intent();
resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
setResult(RESULT_OK, resultValue);
finish();
};

public MyWidgetConfigureActivity() {
super();
}

// Write the prefix to the SharedPreferences object for this widget
static void saveTitlePref(Context context, int appWidgetId, int modeId) {
SharedPreferences.Editor prefs = context.getSharedPreferences(PREFS_NAME, 0).edit();
prefs.putInt(PREF_PREFIX_KEY + appWidgetId, modeId);
prefs.apply();
}


static Integer loadModePref(Context context, int appWidgetId) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0);
int modeId = prefs.getInt(PREF_PREFIX_KEY + appWidgetId, 0);
if (modeId == 0) {
return R.drawable.rs_dark;
} else {
return modeId;
}
}

static void deleteModePref(Context context, int appWidgetId) {
SharedPreferences.Editor prefs = context.getSharedPreferences(PREFS_NAME, 0).edit();
prefs.remove(PREF_PREFIX_KEY + appWidgetId);
prefs.apply();
}

@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setResult(RESULT_CANCELED);
setContentView(R.layout.my_widget_configure);

btDarkMode = findViewById(R.id.btDarkMode);
btLightMode = findViewById(R.id.btLightMode);
btAddWidget = findViewById(R.id.btAddWidget);

btDarkMode.setOnClickListener(darkClickListener);
btLightMode.setOnClickListener(lightClickListener);
btAddWidget.setOnClickListener(addWidgetClickListener);

// Find the widget id from the intent.
Intent intent = getIntent();
Bundle extras = intent.getExtras();
if (extras != null) {
mAppWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
}

// If this activity was started with an intent without an app widget ID, finish with an error.
if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish();
}
}
}

2. Set the widget background base on the user selection.

MyWidget.java

static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {

int modeId = MyWidgetConfigureActivity.loadModePref(context, appWidgetId);
CharSequence loadingText = context.getString(R.string.loading);

// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.my_widget);
views.setTextViewText(R.id.appwidget_text, loadingText);
views.setInt(R.id.rlWidget, "setBackgroundResource", modeId);

// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);
}

Widget layout

Our widget layout will be consist of two “TextView” elements for displaying the joke “setup” and the joke “punchline” and one “Button” for getting a new random joke.

app/res/layout/my_widget.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/rs_dark"
android:padding="@dimen/widget_margin"
android:id="@+id/rlWidget"
android:orientation="vertical"
android:gravity="center"
android:theme="@style/ThemeOverlay.JokesWidget.AppWidgetContainer">

<TextView
android:id="@+id/tvJokeSet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:lines="2"
android:gravity="center"
android:text="@string/loading"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold|italic" />
<TextView
android:id="@+id/tvJokePunch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:lines="2"
android:gravity="center"
android:text="@string/click_for_punchline"
android:textColor="@color/light_blue_900"
android:textSize="16sp"
android:textStyle="bold|italic" />
<TextView
android:theme="@style/Theme.AppCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:padding="6dp"
android:background="@drawable/round_frame"
android:textSize="14sp"
android:text="@string/random_joke"
/>
</LinearLayout>

Create a round_frame.xml drawable for the widget button view background

<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<stroke
android:width="1dp"
android:color="@color/white" />
<corners android:radius="10dp" />
<padding
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
android:top="5dp" />
</shape>

Widget click events handling

To make our widget responsive,

Click on the punchline will display the joke punchline and click on the “Random joke” button will display a new joke.

We will set an “onClickListener” for our joke “setup” and “punchline” using pending intent (“setOnClickPendingIntent”) and overriding the onReceive method.

MyWidget.java

package com.my.jokeswidget;

import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.util.Pair;
import android.widget.RemoteViews;

import java.util.Random;

public class MyWidget extends AppWidgetProvider {

private static final String RANDOM_JOKE_CLICKED = "widgetRandomJokeClick";
private static final String PUNCHLINE_CLICKED = "widgetJokePunchlineClick";
private static Pair<String, String> randJoke;

static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {

// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.my_widget);
CharSequence loadingText = context.getString(R.string.loading);
views.setTextViewText(R.id.tvJokeSet, loadingText);

// Widget drawable background by user selection
int modeId = MyWidgetConfigureActivity.loadModePref(context, appWidgetId);
views.setInt(R.id.rlWidget, "setBackgroundResource", modeId);

// Setup new joke
randJoke = getNewJoke();
views.setTextViewText(R.id.tvJokeSet, randJoke.first);

// Click intents
views.setOnClickPendingIntent(R.id.tvRandomJoke,
getPendingSelfIntent(context, RANDOM_JOKE_CLICKED));
views.setOnClickPendingIntent(R.id.tvJokePunch,
getPendingSelfIntent(context, PUNCHLINE_CLICKED));

// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);
}

protected static PendingIntent getPendingSelfIntent(Context context, String action) {
Intent intent = new Intent(context, MyWidget.class);
intent.setAction(action);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}

@Override
public void onReceive(Context context, Intent intent) {
super.onReceive(context, intent);

if (RANDOM_JOKE_CLICKED.equals(intent.getAction())) {
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_widget);
ComponentName jokeWidget = new ComponentName(context, MyWidget.class);
randJoke = getNewJoke();
remoteViews.setTextViewText(R.id.tvJokeSet, randJoke.first);
remoteViews.setTextViewText(R.id.tvJokePunch, context.getString(R.string.click_for_punchline));
appWidgetManager.updateAppWidget(jokeWidget, remoteViews);
}

if (PUNCHLINE_CLICKED.equals(intent.getAction())) {
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_widget);
ComponentName jokeWidget = new ComponentName(context, MyWidget.class);
remoteViews.setTextViewText(R.id.tvJokePunch, randJoke.second);
appWidgetManager.updateAppWidget(jokeWidget, remoteViews);
}
}

private static Pair<String, String> getNewJoke(){
int rand = new Random().nextInt(100);
return new Pair<>("Random joke number: " + rand,
"Punchline number: " + rand);
}

@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}

@Override
public void onDeleted(Context context, int[] appWidgetIds) {
// When the user deletes the widget, delete the preference associated with it.
for (int appWidgetId : appWidgetIds) {
MyWidgetConfigureActivity.deleteModePref(context, appWidgetId);
}
}

@Override
public void onEnabled(Context context) {
// Enter relevant functionality for when the first widget is created
}

@Override
public void onDisabled(Context context) {
// Enter relevant functionality for when the last widget is disabled
}
}

Before we proceed to the next topic let’s debug the widget and check that everything is working so far.

In order to be able to debug the widget, you will need to set the configuration activity as the main and launcher activity.

AndroidManifest.xml

<activity android:name=".MyWidgetConfigureActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

HTTP client with google volley

Creating a rest client for our jokes API using google volley

  1. Import google volley, module build.gradle
dependencies {
...
implementation 'com.android.volley:volley:1.2.0'
...
}

2. Add network permissions to the AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

3. We will manage our GET requests using volley request queue singleton class and a callback interface, then we will create a “JokeClient” for fetching a random joke from the API.

Create a new package named “httpclient” and create the following class and interface under the new package,

VolleyRQSingleton.java

package com.my.jokeswidget.httpclient;

import com.android.volley.DefaultRetryPolicy;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.toolbox.Volley;
import android.content.Context;

public class VolleyRQSingleton {
private static VolleyRQSingleton mInstance;
private RequestQueue mRequestQueue;
private static Context mCtx;

private VolleyRQSingleton(Context context) {
mCtx = context;
mRequestQueue = getRequestQueue();
}

public static synchronized VolleyRQSingleton getInstance(Context context) {
if (mInstance == null) {
mInstance = new VolleyRQSingleton(context);
}
return mInstance;
}

public RequestQueue getRequestQueue() {
if (mRequestQueue == null) {

mRequestQueue = Volley.newRequestQueue(mCtx.getApplicationContext());
}
return mRequestQueue;
}

public <T> void addToRequestQueue(Request<T> req) {
req.setRetryPolicy(new DefaultRetryPolicy(
5000,
DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));

getRequestQueue().add(req);
}
}

VolleyCallback.java

package com.my.jokeswidget.httpclient;

public interface VolleyCallback {

void onSuccess(Boolean isSuccess);
}

JokeClient.java

package com.my.jokeswidget.httpclient;

import android.content.Context;
import android.util.Pair;

import com.android.volley.Request;
import com.android.volley.toolbox.JsonObjectRequest;

import org.json.JSONException;

public class JokesClient {

private Pair<String, String> joke;

public void fetchJokeFromServer(Context context, final VolleyCallback callback) {
String url = "https://official-joke-api.appspot.com/jokes/random";
JsonObjectRequest jsonRequest = new JsonObjectRequest(Request.Method.GET, url, null,
response -> {
try {
String setup = (response.getString("setup"));
String punchline = (response.getString("punchline"));
joke = new Pair<>(setup, punchline);
} catch (JSONException e) {
e.printStackTrace();
}
callback.onSuccess(true);
},
error -> {
System.out.println(error);
callback.onSuccess(false);
});

VolleyRQSingleton.getInstance(context).addToRequestQueue(jsonRequest);
}

public Pair<String, String> joke(){
return joke;
}
}

HTTP client usage

Now, we will use the new HTTP client in MyWidget class to display the random jokes in our widget,

Below is the final code for MyWidget.java

package com.my.jokeswidget;

import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.util.Pair;
import android.widget.RemoteViews;

import com.my.jokeswidget.httpclient.JokesClient;


public class MyWidget extends AppWidgetProvider {

private static final String RANDOM_JOKE_CLICKED = "widgetRandomJokeClick";
private static final String PUNCHLINE_CLICKED = "widgetJokePunchlineClick";
private static Pair<String, String> randJoke;
private static final JokesClient jokesClient = new JokesClient();

static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.my_widget);
CharSequence loadingText = context.getString(R.string.loading);
views.setTextViewText(R.id.tvJokeSet, loadingText);

// Widget drawable background by user selection
int modeId = MyWidgetConfigureActivity.loadModePref(context, appWidgetId);
views.setInt(R.id.rlWidget, "setBackgroundResource", modeId);

// Click intents
views.setOnClickPendingIntent(R.id.tvRandomJoke,
getPendingSelfIntent(context, RANDOM_JOKE_CLICKED));
views.setOnClickPendingIntent(R.id.tvJokePunch,
getPendingSelfIntent(context, PUNCHLINE_CLICKED));

// Set a new random joke
ComponentName jokeWidget = new ComponentName(context, MyWidget.class);
setRandomJoke(views, context, appWidgetManager, jokeWidget);
}

protected static PendingIntent getPendingSelfIntent(Context context, String action) {
Intent intent = new Intent(context, MyWidget.class);
intent.setAction(action);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}

@Override
public void onReceive(Context context, Intent intent) {
super.onReceive(context, intent);

if (RANDOM_JOKE_CLICKED.equals(intent.getAction())) {
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_widget);
ComponentName jokeWidget = new ComponentName(context, MyWidget.class);
setRandomJoke(remoteViews, context, appWidgetManager, jokeWidget);
} else if (PUNCHLINE_CLICKED.equals(intent.getAction())) {
AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.my_widget);
ComponentName jokeWidget = new ComponentName(context, MyWidget.class);
remoteViews.setTextViewText(R.id.tvJokePunch, randJoke.second);
appWidgetManager.updateAppWidget(jokeWidget, remoteViews);
}
}

private static void setRandomJoke(RemoteViews remoteViews, Context context,
AppWidgetManager appWidgetManager, ComponentName jokeWidget){

remoteViews.setTextViewText(R.id.tvJokeSet, context.getString(R.string.loading));
remoteViews.setTextViewText(R.id.tvJokePunch, context.getString(R.string.click_for_punchline));
appWidgetManager.updateAppWidget(jokeWidget, remoteViews);

jokesClient.fetchJokeFromServer(context, isSuccess -> {
if (!isSuccess){
randJoke = new Pair<>(context.getString(R.string.loading_failed), "");
} else {
randJoke = jokesClient.joke();
}
remoteViews.setTextViewText(R.id.tvJokeSet, randJoke.first);
appWidgetManager.updateAppWidget(jokeWidget, remoteViews);
});
}

@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}

@Override
public void onDeleted(Context context, int[] appWidgetIds) {
// When the user deletes the widget, delete the preference associated with it.
for (int appWidgetId : appWidgetIds) {
MyWidgetConfigureActivity.deleteModePref(context, appWidgetId);
}
}

@Override
public void onEnabled(Context context) {
// Enter relevant functionality for when the first widget is created
}

@Override
public void onDisabled(Context context) {
// Enter relevant functionality for when the last widget is disabled
}
}

Widget image presentation

To change the widget image display when selecting it from the device widgets, you need to copy your widget image to the drawable folder, then set the new image in the widget xml “android:previewImage” attribute.

res/xml/my_widget_info.xml

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="com.my.jokeswidget.MyWidgetConfigureActivity"
android:initialKeyguardLayout="@layout/my_widget"
android:initialLayout="@layout/my_widget"
android:minWidth="250dp"
android:minHeight="80dp"
android:previewImage="@drawable/jokes_img"
android:resizeMode="vertical|horizontal"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen|keyguard"/>

Conclusion

We created a fully operational widget with a configuration screen that saves the user selection using shared preferences.

We implemented an HTTP client using google volley, with a request queue and a callback, that fetches the jokes from the “official jokes API”.

In addition, we make the widget responsive to user interaction for displaying the joke punchline and for displaying new random jokes.

The project code available at https://github.com/aviadh314/JokesWidget/tree/master

Happy hacking.

--

--