Rafael Sanches

June 13, 2011

Google Analytics lags on Android. How to make it more responsive!

Filed under: analytics, android, maintainability, performance — Tags: , , , — mufumbo @ 5:55 am

Google Analytics can be your best friend in order to track your mobile user behavior. Unfortunately the current Android implementation has performance limitations and the most problematic is that it uses SQLite to store your events.

Everyone who wants to write a responsive app knows that you can’t do SQLite operations in the UI Thread. Having to wrap the Google Analytics calls into a separated thread can be painful, so I wrote a very simple helper to handle it inside threads. I have many tracking events inside “button click” and it was taking about 200ms to execute, it’s too much on the UI Thread. It’s also too much if you have “onCreate” because it will take long time to open your new activity.

This helper is also very wrong because it maintains a static reference to the context. I do this in order to have better numbers on visit and “time on site”. You can just remove the static reference if you don’t like that.

Notice that my implementation has this: “Thread.sleep(3000);”
It means that I don’t want repetitive Google Analytics SQLite to be competing with my app inserts or gets.

This LAG happens because SQLite uses the internal memory which can be very slow depending on many factors, including concurrent SQLite operations or just internal memory without many space.

I hope it helps someone. Here’s the complete code:

package com.mufumbo.android.helper;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import android.content.Context;
import android.util.Log;

import com.google.android.apps.analytics.GoogleAnalyticsTracker;

public class GAHelper {
    String activity;
    static GoogleAnalyticsTracker tracker;
    static int instanceCount = 0;
    long start;

    // Limit the number of events due to outofmemory exceptions of analytics sdk
    final static int MAX_EVENTS_BEFORE_DISPATCH = 200;
    static int eventCount = 0;

    static final ExecutorService tpe = Executors.newSingleThreadExecutor();

    public GAHelper(final Context c, final String activity) {
        this.activity = activity;
        instanceCount++;
        if (tracker == null) {
            tpe.submit(new Runnable() {
                @Override
                public void run() {
                    tracker = GoogleAnalyticsTracker.getInstance();
                    tracker.start(Constants.GOOGLE_ANALYTICS_ID, Constants.GOOGLE_ANALYTICS_DELAY, c.getApplicationContext());
                }
            });
        }
    }

    public void onResume() {
        this.trackPageView("/"+this.activity);
    }

    public synchronized void destroy () {
        instanceCount--;
        if (instanceCount <= 0) {
            tpe.submit(new Runnable() {
                @Override
                public void run() {
                    Log.i(Constants.TAG, "destroying GA");
                    if (tracker != null)
                        tracker.stop();
                    instanceCount = 0;
                }
            });
        }
    }

    protected void tick() throws InterruptedException {
        Thread.sleep(3000);
        this.start = System.currentTimeMillis();
    }

    public void log (final String l) {
        if (Dbg.IS_DEBUG) {
            Dbg.debug("['"+(System.currentTimeMillis()-start)+"']["+eventCount+"] Logging on '"+this.activity+"': "+l);
            if (l.contains(" ")) {
                Log.e(Constants.TAG, "DO NOT TRACK WITH SPACES: "+l, new Exception());
            }
        }

    }

    public void trackClick(final String button) {
        checkDispatch();
        tpe.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    tick();
                    tracker.trackEvent(
                            "clicks",  // Category
                            activity+"-button",  // Action
                            button, // Label
                            1);
                    log("trackClick:"+button);
                } catch (final Exception e) {
                    Log.e(Constants.TAG, "Error tracking", e);
                }
            }
        });
    }

    public void trackEvent (final String category, final String action, final String label, final int count) {
        checkDispatch();
        tpe.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    tick();
                    tracker.trackEvent(
                            category,  // Category
                            action,  // Action
                            activity+"-"+label, // Label
                            1);
                    log("trackEvent:"+category + "#"+action+"#"+label+"#"+count);
                } catch (final Exception e) {
                    Log.e(Constants.TAG, "Error tracking", e);
                }
            }
        });
    }

    public void trackPopupView (final String popup) {
        checkDispatch();
        tpe.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    tick();
                    final String page = "/"+activity+"/"+popup;
                    tracker.trackPageView(page);
                    log("trackPageView:"+page);
                } catch (final Exception e) {
                    Log.e(Constants.TAG, "Error tracking", e);
                }
            }
        });
    }

    public void trackPageView (final String page) {
        checkDispatch();
        tpe.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    tick();
                    tracker.trackPageView(page);
                    log("trackPageView:"+page);
                } catch (final Exception e) {
                    Log.e(Constants.TAG, "Error tracking", e);
                }
            }
        });
    }

    public void checkDispatch() {
        eventCount++;
        if (eventCount >= MAX_EVENTS_BEFORE_DISPATCH)
            dispatch();
    }

    public void dispatch(){
        eventCount = 0;
        tpe.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    tick();
                    tracker.dispatch();
                    log("dispatched");
                } catch (final Exception e) {
                    Log.e(Constants.TAG, "Error dispatching", e);
                }
            }
        });
    }
}
Advertisements

January 30, 2011

HTC sense ui breaks the way the WebView settings behave

Filed under: android — mufumbo @ 11:32 pm

I am “amused” to discover that HTC sense has changed even the way android breaks to access of a WebView from outside the UI thread.

Since I don’t own a phone with HTC sense, it took me months to understand this and I only fixed the issue thanks to a kind user who sent me logs.

For example, calling this outside from a UI thread works on the normal android SDK’s:
webSettingsObj.setBuiltInZoomControls(true);

Instead, on HTC Sense phones it breaks here:

java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()
 at android.os.Handler.(Handler.java:121)
 at com.htc.multitouch.MultiTouchDetector$MultiTouchHandler.(MultiTouchDetector.java:180)
 at com.htc.multitouch.MultiTouchDetector.(MultiTouchDetector.java:242)
 at android.webkit.WebView$HTCMultiTouch.fnCreateMultiTouchListener(WebView.java:11781)
 at android.webkit.WebView.enableMultiTouch(WebView.java:10893)
 at android.webkit.WebView.updateMultiTouchSupport(WebView.java:905)
 at android.webkit.WebSettings.setBuiltInZoomControls(WebSettings.java:474)

Now, I know know that anything that changes the UI shouldn’t be executed outside of the UI Thread, but still..

January 29, 2011

upload using multipart post using httpclient in android

Filed under: android — mufumbo @ 7:01 pm

A very common caveat, when doing android applications, is fighting to keep the APK size small.

Many applications need the ability to upload binary data to their server and when you arrive there you see that the android SDK doesn’t have the http-client libraries to send multipart posts.

The easiest way is to include the JAR for httpmime and apache_mime4j, but it takes way too much space; 300kb.

To overcome this you can implement your own HttpEntity. In this way the code is:

public class SimpleMultipartEntity implements HttpEntity {

    private final static char[] MULTIPART_CHARS = "-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
        .toCharArray();

    private String boundary = null;

    ByteArrayOutputStream out = new ByteArrayOutputStream();
    boolean isSetLast = false;
    boolean isSetFirst = false;

    public SimpleMultipartEntity() {
        final StringBuffer buf = new StringBuffer();
        final Random rand = new Random();
        for (int i = 0; i &lt; 30; i++) {
            buf.append(MULTIPART_CHARS[rand.nextInt(MULTIPART_CHARS.length)]);
        }
        this.boundary = buf.toString();

    }

    public void writeFirstBoundaryIfNeeds(){
        if(!isSetFirst){
            try {
                out.write(("--" + boundary + "\r\n").getBytes());
            } catch (final IOException e) {
                Log.e(Constants.TAG, e.getMessage(), e);
            }
        }
        isSetFirst = true;
    }

    public void writeLastBoundaryIfNeeds() {
        if(isSetLast){
            return ;
        }
        try {
            out.write(("\r\n--" + boundary + "--\r\n").getBytes());
        } catch (final IOException e) {
            Log.e(Constants.TAG, e.getMessage(), e);
        }
        isSetLast = true;
    }

    public void addPart(final String key, final String value) {
        writeFirstBoundaryIfNeeds();
        try {
            out.write(("Content-Disposition: form-data; name=\"" +key+"\"\r\n").getBytes());
            out.write("Content-Type: text/plain; charset=UTF-8\r\n".getBytes());
            out.write("Content-Transfer-Encoding: 8bit\r\n\r\n".getBytes());
            out.write(value.getBytes());
            out.write(("\r\n--" + boundary + "\r\n").getBytes());
        } catch (final IOException e) {
            Log.e(Constants.TAG, e.getMessage(), e);
        }
    }

    public void addPart(final String key, final String fileName, final InputStream fin){
        addPart(key, fileName, fin, "application/octet-stream");
    }

    public void addPart(final String key, final String fileName, final InputStream fin, String type){
        writeFirstBoundaryIfNeeds();
        try {
            type = "Content-Type: "+type+"\r\n";
            out.write(("Content-Disposition: form-data; name=\""+ key+"\"; filename=\"" + fileName + "\"\r\n").getBytes());
            out.write(type.getBytes());
            out.write("Content-Transfer-Encoding: binary\r\n\r\n".getBytes());

            final byte[] tmp = new byte[4096];
            int l = 0;
            while ((l = fin.read(tmp)) != -1) {
                out.write(tmp, 0, l);
            }
            out.flush();
        } catch (final IOException e) {
            Log.e(Constants.TAG, e.getMessage(), e);
        } finally {
            try {
                fin.close();
            } catch (final IOException e) {
                Log.e(Constants.TAG, e.getMessage(), e);
            }
        }
    }

    public void addPart(final String key, final File value) {
        try {
            addPart(key, value.getName(), new FileInputStream(value));
        } catch (final FileNotFoundException e) {
            Log.e(Constants.TAG, e.getMessage(), e);
        }
    }

    @Override
    public long getContentLength() {
        writeLastBoundaryIfNeeds();
        return out.toByteArray().length;
    }

    @Override
    public Header getContentType() {
        return new BasicHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
    }

    @Override
    public boolean isChunked() {
        return false;
    }

    @Override
    public boolean isRepeatable() {
        return false;
    }

    @Override
    public boolean isStreaming() {
        return false;
    }

    @Override
    public void writeTo(final OutputStream outstream) throws IOException {
        outstream.write(out.toByteArray());
    }

    @Override
    public Header getContentEncoding() {
        return null;
    }

    @Override
    public void consumeContent() throws IOException,
    UnsupportedOperationException {
        if (isStreaming()) {
            throw new UnsupportedOperationException(
            "Streaming entity does not implement #consumeContent()");
        }
    }

    @Override
    public InputStream getContent() throws IOException,
    UnsupportedOperationException {
        return new ByteArrayInputStream(out.toByteArray());
    }

}

January 13, 2011

server-side calls are better for Ad serving. Downside of AFMA/AdMob.

Filed under: android, programming, revenue, technology — mufumbo @ 7:47 pm

Ad networks that offer server-side calls gives us a big advantage. We can simply get a image + link and print on a android WebView.

Due to limitations of the android framework it’s the only way to have non-intrusive ads inside a WebView. Specially when the WebView must be layout_height=”fill_parent”, for a correct scrolling and zoom control.

JavaScript ads are not an option, since the only work around is to inject JavaScript into the WebView which prevents me from working with JavaScript ads that are based on a inline include.

Advantages of the server-side call instead of SDK:
Reduce APK size: I can implement my own WebView to print ads. AdMob SDK takes 140kb, Google AFMA takes 40kb, so 30% of my app is taken from ads sdk’s.
– AFMA SDK is slow: rendering the adsense javascript is slow. I feel my app freezing and coming back after rendering adsense. Admob is much faster.
– The developer have total control to optimize it.

FYI: After the death of Quattro, the best network that is offering server-side calls is Millenial Media.

June 18, 2010

Using native twitter app Intent to share on android

Filed under: android — mufumbo @ 6:36 pm

I have been googling to find the Twitter native app intent that enables to share through it directly, but I couldn’t find any.

ATTENTION: This is just a hack. It’s better to just open the android popup with the Intent.ACTION_SEND intent.

I didn’t had time to test other ways, but the only way to launch the twitter PostActivity directly was to get the ActivityInfo instance of it. Please, give me feedback if you know a better way.

What this code does is to query all activities that match with the Intent.ACTION_SEND and then it searches for the “com.twitter.android.PostActivity” intent.

try{
    intent = new Intent(Intent.ACTION_SEND);
    intent.putExtra(Intent.EXTRA_TEXT, message);
    intent.setType("text/plain");
    final PackageManager pm = context.getPackageManager();
    final List activityList = pm.queryIntentActivities(intent, 0);
        int len =  activityList.size();
    for (int i = 0; i < len; i++) {
        final ResolveInfo app = activityList.get(i);
        if ("com.twitter.android.PostActivity".equals(app.activityInfo.name)) {
            final ActivityInfo activity=app.activityInfo;
            final ComponentName name=new ComponentName(activity.applicationInfo.packageName, activity.name);
            intent=new Intent(Intent.ACTION_SEND);
            intent.addCategory(Intent.CATEGORY_LAUNCHER);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
            intent.setComponent(name);
            intent.putExtra(Intent.EXTRA_TEXT, message);
            context.startActivity(intent);
            break;
        }
    }
}
catch(final ActivityNotFoundException e) {
    Log.i("mufumbo", "no twitter native",e );
}

October 18, 2009

RSS parsing optimization for bandwidth and processing time with SAX and httpclient – pooling scripts

Filed under: android, maintainability, performance, programming — Tags: , , , — mufumbo @ 3:55 pm

My server was having a constant income traffic of 1.7mb/s for a service that download RSS from the internet and process them. Basically it need to return the last updates of multiple RSS feeds. It’s a very basic pooling system, but it was downloading too much data for just 15.000 active users. The growth wasn’t looking very feasible..

I was using the ROME java library to parse the XML. So far so good, the problem was that it downloads the whole feed and process it all. With my application scope I don’t need to download the whole RSS, just the new entries that i didn’t downloaded yet.

The solution was to use a custom SAX RSS parser, looping through the “” tags and identifying “”. In this way i can parse item per item, and identify if the current item is not updated, so I can abort the http connection and stop the download of the feed. I wish that ROME had an option to do that, like “stop processing when ‘publishedDate’ minor than..”.

The impact on bandwidth usage and processing time was impressive:

If someone is interested I can post and explain the java class. It’s compatible with com.sun.syndication.feed.synd and uses the SyndEntry and SyndFeed interfaces.

September 17, 2009

My first public android app – craigslist notification


logo

This application alert the users when new stuff is posted to craigslist. In this way they can get the best deals as soon as they are registered.

The application is totally free and doesn’t use ads either in current or future versions. There is no form of monetization associated with this application. I am using my new server that i am renting from hetzner.
The application does not require registration and does not store information about its usage on the server.

Features

  • Enable the user to create notifications for certain keywords;
  • Allow to have all filtering that craigslist.org has;
  • Has all cities that craigslist.org has;
  • Preferences menu for configuring the location and network options;
  • Mark posts as favorite to read at a later time;
  • The relevant posts are downloaded on the phone, so they can be read later without network;
  • Faster navigation, since relevant posts are downloaded in batch;

Technical details

  • It periodically checks for new posts;
  • In each check it download *only* the updated data, which should be
    small after the initial download;
  • If no update is available it does not download anything at all. This proves to use less bandwidth than a normal navigation app, since:
    • Only relevant data is downloaded;
    • It never download duplicated data two times;
    • It’s not like RSS feeds that it’s the phone downloading and pooling the feeds every time.
      The phone only donwload the updated data one and do it just one time.

Bandwidth usage

  • For example, a download of 100 new posts takes 50kb of
    internet bandwidth.
  • The daily bandwidth will depend on the number of
    notifications that you are monitoring and the number of times that
    your notifications get updated.
  • To reduce bandwidth, try to be specific in your notifications.
  • For example: try to use “honda civic” instead of “car”

Screenshots

  • Main screen – notification list

    Main screen. This is where your notifications are listed:

  • Posts listing

    When you click on one notification, you go to the post listing.

    Notice that you can edit the previously configured notification by clicking on this menu item.

  • Post detail

    Once you click on one post you will see this screen which displays the post details.

    You are not required to be connected to the network to see the already downloaded posts.

    Notice that you can mark posts as favorite to read them later. The favorite page is accessed from the main menu.

  • Notification

    When there are new posts the app displays a notification on your phone. This can be disabled in the preferences.

Create a free website or blog at WordPress.com.

%d bloggers like this: