Skip to content

Instantly share code, notes, and snippets.

@gitbuh
Created June 19, 2012 18:46
Show Gist options
  • Save gitbuh/2955835 to your computer and use it in GitHub Desktop.
Save gitbuh/2955835 to your computer and use it in GitHub Desktop.
Quick Patch v0.0.3 pre-alpha
(function(){
// sl4a http://code.google.com/p/android-scripting/wiki/ApiReference
// rhino https://developer.mozilla.org/en/Rhino_Shell#Predefined_Properties
// java.io http://docs.oracle.com/javase/6/docs/api/java/io/package-summary.html
var GLOBAL = (function () { return this || (0, eval)('this'); }());
// EXPORTS
GLOBAL.main = main;
var app = {
title: "Quick Patch",
version: "0.0.4 pre-alpha",
configFile: "/mnt/sdcard/quickpatch.json"
};
var settings = {
storage: "",
patches: {},
gists: {}
};
/**
Custom input handlers.
@see inputHandler.sz, inputHandler.byte
@namespace
*/
var inputHandler = {};
var droid;
var mounts = {};
var mountCount = 0;
//
// UTIL
//
/**
Make toast
*/
function toast(x) {
return droid.makeToast(x);
}
/**
Simple dialog
*/
function dlg(title, body, yes, no) {
droid.dialogCreateAlert(title, body);
if (yes) droid.dialogSetPositiveButtonText(yes);
if (no) droid.dialogSetNegativeButtonText(no);
droid.dialogShow();
var response = droid.dialogGetResponse();
droid.dialogDismiss();
return response;
}
/**
Do stuff as root
*/
function sudo() {
var cmd = [].join.call(arguments, "\n");
var tmp = settings.storage + '/quickpatch-cmd';
var writer = new java.io.FileWriter(tmp);
writer.write(cmd);
writer.close();
runCommand("su", "-c", ". " + tmp);
}
/**
Assert
*/
function assert(val, comment) {
print("Assert: " + comment);
if (val !== true) {
throw("Assert failed!");
}
print("(OK)");
}
/**
Convert value to number.
Used for byte offset, initial patch data, simple options, etc.
@param {string|number|Array} val
Strings are treated as hexadecimal numbers. Arrays are processed recursively.
@return {number|Array.<number>}
*/
function readNum(val) {
var i, result = [];
// number, read as decimal
if (val === +val) {
return Math.abs(val);
}
// string, read as hexadecimal
if (val === '' + val) {
return parseInt(val, 16);
}
// array, read recursively
if (val.length) {
for (i = 0; i < val.length; i++) {
result.push(readNum(val[i]));
}
return result;
}
throw "Bad numeric value: " + val;
}
//
// SETTINGS
//
/**
Save settings.
Save settings (including installed patches) to a JSON file.
*/
function saveSettings() {
var writer;
try {
writer = new java.io.FileWriter(app.configFile);
writer.write(JSON.stringify(settings));
writer.close();
print("Saved settings.");
}
catch (e) {
dlg("Error", '' + e, "Damn.");
}
}
/**
Load saved settings.
*/
function loadSettings() {
var response;
if (!new java.io.File(app.configFile).canRead()) {
print("No settings found...");
while (!settings.storage) {
settings.storage = droid.dialogGetInput(
app.title + ' - ' + "Storage location",
"Where should we store temporary files?",
"/mnt/sdcard"
);
}
saveSettings();
return;
}
try {
settings = JSON.parse(readFile(app.configFile));
print("Loaded settings.");
}
catch (e) {
dlg("Error", '' + e, "Damn.");
}
}
//
// IO
//
/**
Write bytes to target file at offset.
@param {string} target
@param {number} offset
@param {Array.<number>} bytes
*/
function writeBytesToFile(target, offset, bytes) {
// Assume the file is only writable by root, and the script is not running as root.
// We make a temporary file, use java.io to write the bytes to it, and then copy
// the bytes in that file to the target as root using dd in the shell.
var tmp = settings.storage + '/quickpatch-tmp',
raf, i;
// Create temp file
sudo('rm ' + tmp, 'touch ' + tmp, 'chmod 666 ' + tmp);
// Write bytes to temp file
raf = new java.io.RandomAccessFile(tmp, "rw");
for (i = 0; i < bytes.length; i++) {
raf.write(bytes[i]);
}
raf.close();
// Copy temp file into target at offset as root (dd)
sudo(
'busybox dd if=' + tmp + ' of=' + target
+ ' seek=' + offset + ' bs=1 count=' + bytes.length
+ ' conv=notrunc',
'rm ' + tmp
);
}
/**
Read bytes from target file at offset.
@param {string} target
@param {number} offset
@param {number} byteCount
@return {Array.<number>} bytes
*/
function readBytesFromFile(target, offset, byteCount) {
// Like writeBytes, we use a temp file to resolve root access issues.
// This time we use dd to write to the temp file and java.io to read it.
var result = [];
var tmp = settings.storage + '/quickpatch-tmp';
var raf;
var i;
sudo(
'dd if=' + target + ' of=' + tmp
+ ' skip=' + offset + ' bs=1 count=' + byteCount,
'chmod 666 ' + tmp,
'hexdump ' + tmp
);
raf = new java.io.RandomAccessFile(tmp, "r");
for (i = byteCount; i--;) {
result.push(raf.read());
}
raf.close();
sudo('rm ' + tmp);
print(JSON.stringify(result));
return result;
}
/**
Bind a new mount point to target's parent directory and remount it as writable.
This is necessary because the file being patched may be on a device mounted as read-only.
@param {string} target
@return {string} writable path to target.
*/
function remount(target) {
if (mounts[target]) return mounts[target];
print('Remounting.');
var segs = target.split("/");
var file = segs.pop();
var path = segs.join("/");
var mp = settings.storage + '/quickpatch-mnt-' + (++mountCount);
mounts[target] = mp + '/' + file;
sudo(
"while [ $? = 0 ]; do umount " + mp + "; done",
"rmdir " + mp,
"mkdir " + mp,
"busybox mount -o bind " + path + " " + mp,
"busybox mount -o remount,rw " + mp,
"mount | grep " + mp,
"ls -l " + mp
);
return mounts[target];
}
/**
Patch the target with the selected option.
@param {Object} option
@param {string} target
*/
function writeOption(patch, option, target) {
var chunk, byteOffset, handler;
target = remount(target);
for (byteOffset in option) {
chunk = option[byteOffset];
handler = inputHandler[chunk.type];
if (handler) {
print('handler: ' + handler + '\n' + JSON.stringify(chunk));
chunk = handler(patch, option, byteOffset);
print('... ' + JSON.stringify(chunk));
}
writeBytesToFile(target, readNum(byteOffset), readNum(chunk));
}
// TODO: Tear down mount point. Leaving it set up for now as it makes things easier to test.
}
/**
Determine currently installed patch option.
@param {Array} patches
@param {string} target
@return {number} The index in `patches` of the currently installed patch.
*/
function getCurrentPatchOption(patches, target) {
var i, byteOffset, chunk, bytesInFile;
checkingPatches: for (i = 0; i < patches.length; i++) {
for (byteOffset in patches[i]) {
chunk = patches[i][byteOffset];
if (chunk.type) continue; // TODO: Check valid object chunks
bytesInFile = readBytesFromFile(target, readNum(byteOffset), chunk.length);
if (readNum(chunk).join(' ') != bytesInFile.join(' ')) continue checkingPatches;
}
return i;
}
// Didn't find any matches
return false;
}
/**
Load a patch from a URL.
Called when installing and updating patches.
@param {string} url
@return {Object} Patch data.
*/
function loadPatch(url) {
var data;
try {
data = JSON.parse(readUrl(url));
if (!(data && data.title)) {
toast('Invalid patch.');
return;
}
settings.patches[data.title] = data;
saveSettings();
return data;
}
catch (e) {
toast('' + e);
}
}
/**
Load patches from a github gist.
Called when installing and updating gists.
@param {string} id
@return {Object} Gist data.
*/
function loadGist(id) {
var data, patch, hasPatches;
try {
data = JSON.parse(readUrl('https://api.github.com/gists/' + id));
if (!(data && data.files)) {
toast('Invalid gist.');
return;
}
// remove old patches
for (var key in settings.patches) {
var part = key.split('/');
if (part[0] == id && !data.files[part[1]]) {
print("removing patch " + key);
delete settings.patches[key];
hasPatches = true;
}
}
// install new patches
for (var key in data.files) {
patch = JSON.parse(data.files[key].content);
if (!(patch && patch.title)) {
continue;
}
settings.patches[id + '/' + key] = patch;
hasPatches = true;
}
if (hasPatches) {
if (!settings.gists) settings.gists = {};
settings.gists[id] = data.description || id;
saveSettings();
return data;
}
}
catch (e) {
toast('' + e);
}
}
//
// UI
//
/**
Show the add patch dialog.
*/
function addPatch() {
var response,
data;
while (1) {
response = droid.dialogGetInput(
app.title + ' - ' + "Add Patch",
"Enter the patch URL:",
response || "http://"
);
if (!response) return;
if (loadPatch(response)) {
toast("Patch added.");
return;
}
}
}
function addGist() {
var response,
data;
while (1) {
response = droid.dialogGetInput(
app.title + ' - ' + "Add Gist",
"Enter the gist ID:",
response || ""
);
if (!response) return;
if (loadGist(response)) {
toast("Gist added.");
return;
}
}
}
function getPatchOptions(options, patches) {
for (key in settings.patches) {
settings.patches[key].key = key;
patches.push(settings.patches[key]);
}
patches.sort(function (a, b) { return a.title > b.title; });
for (i = 0; i < patches.length; ++i) {
options.push(patches[i].title);
}
}
/**
Show the patch dialog.
*/
function patch() {
// TODO: Refactor into two parts, patch list and individual patch.
var target, raf, key, options, patches, response, p, sel, current, i;
do {
options = [];
patches = [];
getPatchOptions(options, patches);
if (options.length) {
droid.dialogCreateAlert(app.title);
} else {
droid.dialogCreateAlert(app.title, "No patches installed.");
}
droid.dialogSetPositiveButtonText("Manage Patches");
droid.dialogSetNeutralButtonText("Manage Gists");
droid.dialogSetItems(options);
droid.dialogShow();
response = droid.dialogGetResponse();
droid.dialogDismiss();
if (!response) break;
if (response.which == "positive") {
managePatches();
response.item = 0;
continue;
}
if (response.which == "neutral") {
manageGists();
response.item = 0;
continue;
}
if (response.item > -1) {
p = patches[response.item];
// target = checkTarget(p);
target = p.target;
if (!target) continue;
options = ['Unpatched'];
patches = [p.initial];
for (key in p.options) {
options.push(key);
patches.push(p.options[key]);
}
current = getCurrentPatchOption(patches, target);
if (current === false) {
toast("Warning! Target is in an unknown state.");
current = 1000;
}
if (p.description) {
toast(p.description);
droid.dialogCreateAlert(p.title, p.description);
} else {
droid.dialogCreateAlert(p.title);
}
droid.dialogSetPositiveButtonText("Apply");
droid.dialogSetNegativeButtonText("Cancel");
droid.dialogSetSingleChoiceItems(options, current);
droid.dialogShow();
response = droid.dialogGetResponse();
if (response.which != "positive") {
print("Patch aborted.");
response.item = 0;
continue;
}
sel = droid.dialogGetSelectedItems()[0];
droid.dialogDismiss();
print("response: " + JSON.stringify(response));
print("selected: " + JSON.stringify(sel));
try {
writeOption(p, patches[sel], target);
dlg("Success", "File was patched.", "Sweet.");
}
catch (e) {
dlg("Error", '' + e, "Damn.");
}
response.item = 0;
continue;
}
} while (response.item > -1);
}
/**
Show the manage patches dialog.
*/
function managePatches() {
var title = app.title + ' - Manage Patches',
key, options, patches, response, sel, state, i;
while (1) {
options = [];
patches = [];
getPatchOptions(options, patches);
if (options.length) {
droid.dialogCreateAlert(title);
} else {
droid.dialogCreateAlert(title, "No patches installed.");
}
droid.dialogSetPositiveButtonText("Add Patch");
droid.dialogSetNegativeButtonText("Delete");
droid.dialogSetMultiChoiceItems(options);
droid.dialogShow();
response = droid.dialogGetResponse();
if (!response || response.canceled) return;
sel = droid.dialogGetSelectedItems();
print(JSON.stringify(response));
print(JSON.stringify(sel));
droid.dialogDismiss();
if (!response || response.which == "neutral") {
continue;
}
if (response.which == "positive") {
addPatch();
continue;
}
if (response.which == "negative") {
if (!sel || sel.length < 1) {
toast("Nothing to delete.");
continue;
}
var desc = sel.length > 1 ? sel.length + " patches" : patches[sel[0]].title;
var r = dlg(
app.title + " - Deleting patches",
"Delete " + desc + "?",
"Delete", "Cancel");
if (!r || r.which == 'negative') continue;
for (var i = sel.length; i--;) {
delete settings.patches[patches[sel[i]].key];
}
saveSettings();
}
}
}
/**
Show the manage gists dialog.
*/
function manageGists() {
var title = app.title + ' - Manage Gists',
key, options, gists, response, sel, state;
while (1) {
options = [];
gists = [];
for (key in settings.gists) {
options.push(settings.gists[key]);
gists.push(key);
}
if (options.length) {
droid.dialogCreateAlert(title);
} else {
droid.dialogCreateAlert(title, "No gists installed.");
}
droid.dialogSetPositiveButtonText("Add Gist");
droid.dialogSetNegativeButtonText("Delete");
droid.dialogSetMultiChoiceItems(options);
droid.dialogShow();
response = droid.dialogGetResponse();
if (!response || response.canceled) return;
sel = droid.dialogGetSelectedItems();
print(JSON.stringify(response));
print(JSON.stringify(sel));
droid.dialogDismiss();
if (!response || response.which == "neutral") {
continue;
}
if (response.which == "positive") {
addGist();
continue;
}
if (response.which == "negative") {
if (!sel || sel.length < 1) {
toast("Nothing to delete.");
continue;
}
var desc = sel.length > 1 ? sel.length + " gists" : settings.gists[gists[sel[0]]];
var r = dlg(
app.title + " - Deleting gists",
"Delete " + desc + "?",
"Delete", "Cancel");
if (!r || r.which == 'negative') continue;
for (var i = sel.length; i--;) {
delete settings.gists[gists[sel[i]]];
}
saveSettings();
}
}
}
/**
Show a dialog to get a zero-terminated string.
@param {Object} option
@return {Array.<number>}
*/
inputHandler.sz = function(patch, option, offset) {
var chunk = option[offset],
maxLen = patch.initial[offset].length,
placeholder = '',
result, response;
while (1) {
result = [];
response = droid.dialogGetInput('' + chunk.title, '' + chunk.info, placeholder);
if (!response) continue;
placeholder = response;
if (response.length >= maxLen) {
toast("Exceeded maximum characters: " + (maxLen - 1));
continue;
}
for (var i = 0; i < response.length; i++) {
result.push(response.charCodeAt(i));
}
while (i++ < maxLen) {
result.push(0);
}
assert(result.length == maxLen, "getCustomSz returns array with correct length");
return result;
}
};
/**
Show a dialog to get a hexidecimal byte value.
@param {Object} option
@return {number}
*/
inputHandler['byte'] = function(patch, option, offset) {
var chunk = option[offset],
placeholder = '',
response;
while (1) {
response = droid.dialogGetInput('' + chunk.title, '' + chunk.info, placeholder);
if (!response) continue;
placeholder = response.substring(0, 2);
if (response.length > 2) {
continue;
}
return [readNum(response)];
}
};
function checkPatchUpdates() {
toast("Checking patch updates...");
for (var key in settings.gists) {
loadGist(key);
}
}
//
// MAIN
//
/**
Program entrypoint. Called from the loader script.
*/
function main(storage) {
var patchId = -1,
// targetId = -1,
manageId = -1,
options,
response;
load("/sdcard/com.googlecode.rhinoforandroid/extras/rhino/android.js");
droid = new Android();
toast("\n" + app.title + " v" + app.version + "\n");
if (storage) settings.storage = storage;
loadSettings();
checkPatchUpdates();
patch();
}
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment