|
A funny thing happened when I wrote my last app. This was a basic, local data-driven logging system which was entirely self contained. Some of the testers reported something odd - it asked for the permission to make phone calls on installation. Since I knew there was nothing in the app which did that, and I'd only added the android.permission.INTERNET requirement to the manifest, I knew I had to get rid of this warning. In this security-conscious climate, any app which asks for something so far out of whack with its obvious purpose probably isn't going to be installed, and all that hard work will have been for nothing. So what gives? The answer was rather subtle, and in solving it I came up with a useful tool which went beyond the original problem I was tackling, so I thought I'd document it here in case anyone else runs into it.
What goes in an About box?
Your apps About box should tell the user, well, something useful about your app. The usual entries are credits for the author, maybe a link to their site etc, and then the version number. Once I'd written a few apps which do this I soon realised there's a pattern, much of which can be automated. For the contact details a string resource could be used, and the version info can be pulled out at runtime using a system call. This is great - it just means each release all you have to do is update the version entries in the AndroidManifest.xml file once, then at runtime the About box will pull them in correctly. Except, after a little detective work, I realised that's the problem! Heres the way the About box looked:
The information here comes from a call to query the PackageManager object like this:
PackageManager pm = activity.getPackageManager();
String version = activity.getString( R.string.about_unknown );
try {
PackageInfo pi = pm.getPackageInfo(activity.getPackageName(), 0);
version = activity.getString( R.string.about_version ) + " " +
Integer.toString( pi.versionCode )+ "/" + pi.versionName;
} catch (NameNotFoundException e) {
e.printStackTrace();
}
It turns out that to interrogate the system via the Packagexxx objects you are in fact potentially requesting sensitive information on the phones state, such as private numbers dialled etc. To handle this, Android just alerts the user with the "Make phone calls" permission request. Since this isn't what we are doing we need a way round using these objects. Note that since writing this I found out there is a way to help certain Android versions deal with it by restricting the version targets in the manifest. However, since I want to target as wide a range of versions as possible, and the solution also produced a useful bonus you can't get this way, I continue to use what I eventually came up with.
There's more than one way to do that...
Let's look at a typical AndroidManifest.xml - the same as used in the About box shown earlier:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.otamate.drinkminder"
android:versionCode="5"
android:versionName="Alpha 1.0.4">
<application
android:icon="@drawable/icon"
android:label="@string/app_name">
<activity android:name=".DrinkMinderActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".AppSettings"/>
</application>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
Even if you knew nothing about Android development you'd probably be able to make sense of this because above all, it's a regular xml file. That means we can parse it. So if there was a way to do this, and extract the version fields we wanted each build in order to somehow let our app access them when needed, we should be ok. Fortunately there's a trick to this which is one of those things which sounds quite daunting the first time you come across it but really is a no-brainer from then on - write an Eclipse tool. We'll get into that shortly, but first let's see how we'd go about writing a parser (in regular java, not Android), for the manifest file. It's just a couple of files which are so short I can list them both in full here:
ParseManifest.java:
/*
* Copyright (c) 2011 OTAMate Technology Ltd. All Rights Reserved.
* http://www.otamate.com
*/
package com.otamate.parseandroidmanifest;
import java.io.FileWriter;
import java.io.PrintWriter;
public class ParseManifest {
private static String IN_FILENAME = "../AndroidManifest.xml";
private static String OUT_FILENAME = "VersionData.txt";
public static void main(String[] args) {
ParseManifest app = new ParseManifest();
try {
app.process();
} catch (Exception e) {
e.printStackTrace();
}
}
public void process() {
try {
ParseEngine parser = new ParseEngine(
new PrintWriter(new FileWriter(OUT_FILENAME)), IN_FILENAME);
parser.parse();
} catch (Exception e) {
e.printStackTrace ();
}
}
}
ParseEngine.java:
/*
* Copyright (c) 2011 OTAMate Technology Ltd. All Rights Reserved.
* http://www.otamate.com
*/
package com.otamate.parseandroidmanifest;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.helpers.DefaultHandler;
public class ParseEngine extends DefaultHandler {
private static PrintWriter out;
String path;
String versionCode;
String versionName;
public ParseEngine(PrintWriter out, String path) {
ParseEngine.out = out;
this.path = path;
}
public void parse() {
try {
File file = new File(path);
InputStream inputStream = new FileInputStream(file);
Reader reader = new InputStreamReader(inputStream, "UTF-8");
Calendar buildDate = Calendar.getInstance();
SimpleDateFormat formatter = new SimpleDateFormat("dd-MMM-yyyy");
InputSource is = new InputSource(reader);
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
is.setEncoding("UTF-8");
out.println("BuildTimestamp:" + formatter.format(buildDate.getTime()));
saxParser.parse( is, new ParseEngine() );
} catch (Exception e) {
e.printStackTrace ();
} finally {
out.close();
}
}
public ParseEngine() {}
public void startElement(String namespaceURL, String localName,
String qname, Attributes attributes) {
if (qname.compareTo("manifest") == 0) {
out.println("VersionCode:" + attributes.getValue("android:versionCode"));
out.println("VersionName:" + attributes.getValue("android:versionName"));
return;
}
}
}
In short, this is just an XML parser which will create a text file each run. You'll notice it also stamps it with the current date - that's the extra I mentioned earlier which you can't get by querying the Package objects. The output file is called VersionData.txt and looks like this:
BuildTimestamp:08-Jan-2011
VersionCode:5
VersionName:Alpha 1.0.4
Eclipse builders
Eclipse has a neat system which lets you control your projects toolchain. By default, the project type (e.g. Android) sets this up for you and works automatically, so many users are not even aware it exists. It's best described as the set of programs which will be invoked, and the order they will be invoked in, on your project files in order to produce the required final output files. In Eclipse, check it out using Project | Properties | Builder. The idea here is we are going to use the parser just seen (suitably built and packaged up into ParseAndroidManifest.jar) and wire it into the toolchain. That way, when configured correctly, it will read our live AndroidManifest.xml file and put the output VersionData.txt file somewhere we can make use of, and do it automatically each build. Here's how the Eclipse panel will look in our Android project when done - to get this I added the tool (with the "New..." button) and then used the "Up" button to move it to the top:

The ParseAndroidManifest.jar is placed in a folder I manually created underneath the projects root called "tools". Its configuration needs a little care. On Windows here's how it should be set up, adjust the "Location" entry to your java executable if yours is different (e.g. Linux):
This basically says invoke java on the specified jar using your projects /assets folder as a working directory. In other words, its going to create the VersionData.txt file in our assets folder. So, if we know that file will always be present in our App, it's time to start making use of it...
Loading into our Android app
There are a few ways you can make use of this now - it doesn't have to be an About dialog, and if it is it doesn't have to be a Dialog created the way shown here. However, in this project this is how it was done. Notice all that was needed was a way to access the file in /assets and extract the values - this is done in the method getBuildValue(key):
/*
* Copyright (c) 2011 OTAMate Technology Ltd. All Rights Reserved.
* http://www.otamate.com
*/
package com.otamate.drinkminder;
import java.io.BufferedReader;
import java.io.InputStreamReader;
...
public class DialogAbout extends Dialog {
public Activity mActivity;
public View mDialogAboutView;
...
public DialogAbout(Activity activity, int theme) {
super(activity, theme);
mActivity = activity;
LayoutInflater factory = LayoutInflater.from( activity );
mDialogAboutView = factory.inflate( R.layout.dialog_about, null );
String version = activity.getString( R.string.about_version ) + " " +
getBuildValue("VersionCode") + "/" + getBuildValue("VersionName");
...
mButtonOk.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
dismiss();
}
});
}
private String getBuildValue(String key) {
String val = "";
try {
String line;
BufferedReader bufferedReader = new BufferedReader(
new InputStreamReader(mActivity.getAssets()
.open(Constants.VERSIONDATA_FILENAME), "UTF-8"));
while ((line = bufferedReader.readLine()) != null) {
if (line.startsWith(key + ":", 0)) {
val = line.substring(line.indexOf(":") + 1);
break;
}
}
bufferedReader.close();
} catch (Exception e) {
l.d(TAG, "getBuildValue(" + key + ") Error: " + e.toString());
}
return val;
}
}
Constants.VERSIONDATA_FILENAME is just "VersionData.txt".
There are ways this can be extended. Ant builds should have no trouble wiring this in, and in particular any build control systems such as Hudson or Cruise Control which use their own build numbers would be at home stamping VersionData.txt with their build numbers. In any case, there are a few techniques shown here which, when combined, can make life easier when you want that build info without furrowing the brows of your wary users ;-) |