How to write a DashClock Extension: Whatsapp Extension example

After BatteryExtension and DialExtension, now I tried to write an unofficial Whatsapp Extension for DashClock.


It was not so easy...

WhatsApp does not expose an API or official ContentProvider and therefore the only idea I had was to listen for status bar notifications.
It requires you to enable a Accessibility Service to work.

I don't like this way, because it needs relatively deep/dangerous permission authorizations.
At present, I would't install an application that requires this type of authorization.
With these permissions, an app can listen for all notification from all apps...
You can specify which packages you want to listen to and only listen to those package, but users don't have visibility of this information.
It would be a good idea if in the next version of Android this information was available.

You can listen for status bar notifications by using AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED.
We are going to write our WhtNotificationService that extends AccessibilityService.
Here you can find official doc.
public class WhtNotificationService extends AccessibilityService {

   private final static String TAG="WhtNotificationService";
   public static final String PACKAGE_NAME = "com.whatsapp";
 
   @Override
   public void onAccessibilityEvent(AccessibilityEvent event) {

      Log.d(TAG,"new Event="+event.toString());
  
      MessageManager manager = MessageManager.getInstance();
      if (manager != null) {
          if (manager.getmReceiver()!=null && !manager.getmReceiver().isUserActive()){
                MessageWht msg = new MessageWht();
                msg.setText(event.getText());
                manager.notifyListener(msg);
         }
      }
   } 

   @Override
   public void onInterrupt() {}
}
This class listens for whatsapp notifications on status bar.
Here you can find a single event.

D/WhtNotificationService(9122): new Event=EventType: TYPE_NOTIFICATION_STATE_CHANGED;
EventTime: 152881074; PackageName: com.whatsapp; MovementGranularity: 0;
Action: 0 [ ClassName: android.app.Notification;
Text: [Message from Gabriele Mariotti]; ContentDescription: null; ItemCount: -1; CurrentItemIndex: -1; IsEnabled: false; IsPassword: false; IsChecked: false;
IsFullScreen: false; Scrollable: false; BeforeText: null; FromIndex: -1; ToIndex: -1; ScrollX: -1; ScrollY: -1;
MaxScrollX: -1; MaxScrollY: -1; AddedCount: -1; RemovedCount: -1;
ParcelableData: Notification(pri=0 contentView=com.whatsapp/0x102013f vibrate=null sound=null defaults=0x0 flags=0x0
kind=[null]) ]; recordCount: 0

We have to declare it in Manifest file:

 <service android:name=".WhtNotificationService" android:enabled="true"
       android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
      <intent-filter>
          <action android:name="android.accessibilityservice.AccessibilityService" />
       </intent-filter>
        <meta-data android:name="android.accessibilityservice"
                             android:resource="@xml/accessibilityservice" />
 </service>
In this case we chose to use a config xml file (we can configure our Accessibility Service with code).

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeNotificationStateChanged"
    android:accessibilityFeedbackType="feedbackVisual"
    android:notificationTimeout="100"
    android:packageNames="com.whatsapp"
    android:description="@string/accessibility_service_description"/>
In this file we configure events type and packageNames from which we will listen notifications.
Also android:description (that I didn't find in doc) is very important because it is the only way to explain users why you are going to use this service.
After you install the application you must activate accessibility service from Accessibility item in Android Settings

In the event there aren't much information. The only method useful in this case is getText() which returns a [Message from Gabriele Mariotti]. It not so easy to parse this text because it depends from language and from sender name.

I used a MessageManager to connect accessibility service with DashClockExtension.
public class MessageManager {

   private final static String TAG="MessageManager";
 
   private WhatsappExtension mWhtExtension;
   private static MessageManager sInstance;
   private SomethingOnScreenReceiver mReceiver;

   public static MessageManager getInstance(WhatsappExtension context) {
     if (sInstance == null) {
         sInstance = new MessageManager(context);
     }
     return sInstance;
   }

   public static MessageManager getInstance() {
     return sInstance;
   }

   private MessageManager(WhatsappExtension context) {
     mWhtExtension = context;
     mCount = 0;
     mMsgs = new ArrayList();
   }
 
   private int mCount;
   private ArrayList mMsgs;

   /**
    * Notify for new Message
    * @param msg
    */
   public void notifyListener(MessageWht msg) {
      Log.d(TAG,"new Message");
      if (mWhtExtension != null){  
         mCount++;
         mMsgs.add(msg);
         mWhtExtension.changeMessage();
      }
   }
 
   /**
    * Reset counter and clear old messages
    */
    public void clearMessages(){
       Log.d(TAG,"Clear Messages");
       mCount=0;
       mMsgs=new ArrayList();
       if (mWhtExtension!=null)
          mWhtExtension.changeMessage();
    }

    public int getmCount() {
       return mCount;
    }

    public ArrayList getmMsgs() {
       return mMsgs;
    }

    public SomethingOnScreenReceiver getmReceiver() {
       return mReceiver;
    }

    public void setmReceiver(SomethingOnScreenReceiver mReceiver) {
       this.mReceiver = mReceiver;
    }
}
This class stores information about messages (count and text).
Below our dashclock extension:
public class WhatsappExtension extends DashClockExtension {

   private static final String TAG = "WhatsappExtension";
   private MessageManager mManager;
   private SomethingOnScreenReceiver mReceiver;

   @Override
   protected void onInitialize(boolean isReconnect) {
      super.onInitialize(isReconnect);
      if (!isReconnect) {
          mManager = MessageManager.getInstance(this); 
          registerReceiver(); 
          mManager.setmReceiver(mReceiver);
       }
    }

    public void changeMessage() {
       onUpdateData(UPDATE_REASON_CONTENT_CHANGED);
    }

    @Override
    protected void onUpdateData(int reason) {
         if (mManager!=null){
            Log.d(TAG,"onUpdateData msgCount="+mManager.getmCount());
            if (mManager.getmCount() > 0) {
                // publish
                publishUpdateExtensionData();
            } else
                clearUpdateExtensionData();
            }
    }

    /**
     * Clear DashClock
     */
     private void clearUpdateExtensionData() { 
         publishUpdate(null);
     }

    /**
     * publishUpdata
     */
     private void publishUpdateExtensionData() {
  
         if (mManager!=null){
            // Intent
            PackageManager pm = getPackageManager();
            Intent intentWht=pm.getLaunchIntentForPackage(WhtNotificationService.PACKAGE_NAME);
   
            int mCount=mManager.getmCount();
            ExtensionData data= new ExtensionData().visible(true)
                               .icon(R.drawable.ic_extension_wht)
                               .status(""+mCount)
                               .expandedTitle(getResources().getQuantityString(
                            R.plurals.messagecount, mCount,
                             mCount));
   
     if (intentWht!=null)
                data.clickIntent(intentWht);
   
     // Publish the extension data update.
     publishUpdate(data);
 }
}
With this code when a new message will be notified, the accessibility service will catch the event, and through the MessageManager, it will update dashclock extension.


The last issue was how to clear messages and message count when user is present after device wakes up.
The ideal would be a intent Broadcast from Notification Area, but I don't know about it.
I chose to have a broadcast receiver in DashClock Extension:
   private void registerReceiver(){
        IntentFilter localIntentFilter = new IntentFilter();
        localIntentFilter.addAction(Intent.ACTION_SCREEN_OFF);
        localIntentFilter.addAction(Intent.ACTION_SCREEN_ON);
        localIntentFilter.addAction(Intent.ACTION_USER_PRESENT);
        MessageManager manager = MessageManager.getInstance();
        if (manager!=null){
           mReceiver = new SomethingOnScreenReceiver(manager);
           registerReceiver(mReceiver, localIntentFilter);
        }
   }
   
   @Override
   public void onDestroy() {
        // TODO Auto-generated method stub
        super.onDestroy();
        if (mReceiver!=null)
            unregisterReceiver(mReceiver);
   }
The Broadcast Receiver will reset counter and messages.
public class SomethingOnScreenReceiver extends BroadcastReceiver {

    private boolean userActive = false;
    private boolean onScreen = false;
    private MessageManager mManager;
 
    public SomethingOnScreenReceiver(MessageManager manager){
        mManager=manager;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
  
       if (intent.getAction().equals(Intent.ACTION_SCREEN_ON)) {
           onScreen = true;
           userActive=false;
       }else if (intent.getAction().equals(Intent.ACTION_SCREEN_OFF)){
           onScreen=false;
           userActive=false;
       }else if (intent.getAction().equals(Intent.ACTION_USER_PRESENT)){
           userActive=true;
           if (onScreen && mManager!=null){
               mManager.clearMessages();
           }
       }
    }

    public boolean isUserActive() {
       return userActive;
    }
}


You can get code from GitHub:

Comments

Popular posts from this blog

AntiPattern: freezing a UI with Broadcast Receiver

NotificationListenerService and kitkat

How to centralize the support libraries dependencies in gradle