package de.blau.android.util; import java.io.BufferedOutputStream; import java.io.Closeable; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InvalidClassException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.io.Serializable; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import org.acra.ACRA; import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.media.MediaScannerConnection; import android.media.MediaScannerConnection.OnScanCompletedListener; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.support.annotation.NonNull; import android.util.Log; import de.blau.android.R; /** * Helper class for loading and saving individual serializable objects to files. * Does all error handling, stream opening etc. * * @param <T> The type of the saved objects */ public class SavingHelper<T extends Serializable> { private static final String DEBUG_TAG = SavingHelper.class.getSimpleName(); /** * Date pattern used for the export file name. */ private static final String DATE_PATTERN_EXPORT_FILE_NAME_PART = "yyyy-MM-dd'T'HHmmss"; /** * Serializes the given object and writes it to a private file with the given name * * Original version was running out of stack, fixed by moving to a thread * * @param filename filename of the save file * @param object object to save * @param compress true if the output should be gzip-compressed, false if it should be written without compression * @return true if successful, false if saving failed for some reason */ public synchronized boolean save(Context context, String filename, T object, boolean compress) { try { Log.d("SavingHelper", "preparing to save " + filename); SaveThread r = new SaveThread(context, filename, object, compress); Thread t = new Thread(null, r, "SaveThread", 200000); t.start(); t.join(60000); // wait max 60 s for thread to finish TODO this needs to be done differently given this limits the size of the file that can be saved Log.d("SavingHelper", "save thread finished"); return r.getResult(); } catch (Exception e) { ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // serious error report if we has crashed return false; } } public class SaveThread implements Runnable { final String filename; T object; final boolean compress; final Context context; boolean result = false; SaveThread(Context context, String fn, T obj, boolean c) { filename = fn; object = obj; compress = c; this.context = context; } public boolean getResult() { return result; } @Override public void run() { OutputStream out = null; ObjectOutputStream objectOut = null; try { Log.i("SavingHelper", "saving " + filename); String tempFilename = filename + "." + System.currentTimeMillis(); out = context.openFileOutput(tempFilename, Context.MODE_PRIVATE); if (compress) { out = new GZIPOutputStream(out); } objectOut = new ObjectOutputStream(out); objectOut.writeObject(object); rename(context, filename, filename + ".backup"); // don't overwrite last saved state rename(context, tempFilename, filename); // rename to expected name Log.i("SavingHelper", "saved " + filename + " successfully"); result = true; } catch (Exception e) { Log.e("SavingHelper", "failed to save "+filename, e); ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // serious error report as if we had crashed result = false; } catch (Error e) { Log.e("SavingHelper", "failed to save "+filename, e); ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // serious error report as if we had crashed result = false; } finally { SavingHelper.close(objectOut); SavingHelper.close(out); } } } /** * Loads and deserializes a single object from the given file * Original version was running out of stack, fixed by moving to a thread * * @param filename filename of the save file * @param compressed true if the output is gzip-compressed, false if it is uncompressed * @return the deserialized object if successful, null if loading/deserialization/casting failed */ public synchronized T load(Context context, String filename, boolean compressed) { return load(context, filename, compressed, false); } private synchronized T load(Context context, String filename, boolean compressed, boolean deleteOnFail) { try { Log.d("SavingHelper", "preparing to load " + filename); LoadThread r = new LoadThread(context, filename, compressed, deleteOnFail); Thread t = new Thread(null, r, "LoadThread", 200000); t.start(); t.join(60000); // wait max 60 s for thread to finish TODO this needs to be done differently given this limits the size of the file that can be loaded Log.d("SavingHelper", "load thread finished"); return r.getResult(); } catch (Exception e) { ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // serious error report as if we had crashed return null; } } public class LoadThread implements Runnable { final String filename; final boolean compressed; final boolean deleteOnFail; final Context context; T result; LoadThread(Context context, String fn, boolean c, boolean deleteOnFail) { filename = fn; compressed = c; this.deleteOnFail = deleteOnFail; this.context = context; } public T getResult() { return result; } @Override public void run() { InputStream in = null; ObjectInputStream objectIn = null; try { Log.d("SavingHelper", "loading " + filename); try { in = context.openFileInput(filename); } catch (FileNotFoundException fnfe) { // this happens a lot and shouldn't generate an error report Log.e("SavingHelper", "file not found " + filename); result = null; return; } if (compressed) { in = new GZIPInputStream(in); } objectIn = new ObjectInputStream(in); @SuppressWarnings("unchecked") // casting exceptions are caught by the exception handler T object = (T) objectIn.readObject(); Log.d("SavingHelper", "loaded " + filename + " successfully"); result = object; } catch (IOException ioex) { Log.e("SavingHelper", "failed to load " + filename, ioex); try { if (deleteOnFail) { context.deleteFile(filename); } } catch (Exception ex) { // ignore } result = null; } catch (Exception e) { Log.e("SavingHelper", "failed to load " + filename, e); result = null; if (e instanceof InvalidClassException) { // serial id mismatch, will typically happen on upgrades // do nothing } else { ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // } } catch (Error e) { Log.e("SavingHelper", "failed to load " + filename, e); result = null; ACRA.getErrorReporter().putCustomData("STATUS","NOCRASH"); ACRA.getErrorReporter().handleException(e); // } finally { SavingHelper.close(objectIn); SavingHelper.close(in); } } } /** * Convenience function - closes the given stream (can be any Closable), catching and logging exceptions * @param stream a Closeable to close */ static public void close(final Closeable stream) { if (stream != null) { try { stream.close(); } catch (IOException e) { Log.e("SavingHelper", "Problem closing", e); } } } /** * Rename existing file in same directory if target file exists, delete * Code nicked from http://stackoverflow.com/users/325442/mr-bungle * @param context * @param originalFileName * @param newFileName */ private static void rename(Context context, String originalFileName, String newFileName) { File originalFile = context.getFileStreamPath(originalFileName); if (originalFile.exists()) { Log.d("SavingHelper", "renaming " + originalFileName + " size " + originalFile.length() + " to " + newFileName); File newFile = new File(originalFile.getParent(), newFileName); if (newFile.exists()) { context.deleteFile(newFileName); } originalFile.renameTo(newFile); } } /** * Exports an Exportable asynchronously, displaying a toast on success or failure * * @param ctx context for the toast * @param exportable the exportable to run */ public static void asyncExport(@NonNull final Context ctx, @NonNull final Exportable exportable) { new AsyncTask<Void, Void, String>() { @Override protected String doInBackground(Void... params) { String filename = DateFormatter .getFormattedString(DATE_PATTERN_EXPORT_FILE_NAME_PART) + "." + exportable.exportExtension(); OutputStream outputStream = null; File outfile = null; FileOutputStream fout = null; try { File outDir = FileUtil.getPublicDirectory(); outfile = new File(outDir, filename); fout = new FileOutputStream(outfile); outputStream = new BufferedOutputStream(fout); exportable.export(outputStream); } catch (Exception e) { Log.e("SavingHelper", "Export failed - " + filename); return null; } finally { SavingHelper.close(outputStream); SavingHelper.close(fout); } // workaround for android bug - make sure export file shows up via MTP if (ctx != null && outfile != null){ try { triggerMediaScanner(ctx, outfile); } catch (Exception ignored) { Log.e(DEBUG_TAG,"Toast in asyncExport failed with " + ignored.getMessage()); } catch (Error ignored) { Log.e(DEBUG_TAG,"Toast in asyncExport failed with " + ignored.getMessage()); } } return filename; } @Override protected void onPostExecute(String result) { if (ctx != null) { try { if (ctx instanceof Activity) { if (result == null) { Snack.barError((Activity)ctx, R.string.toast_export_failed); } else { Log.i("SavingHelper", "Successful export to " + result); String text = ctx.getResources().getString(R.string.toast_export_success, result); Snack.barInfoShort((Activity)ctx, text); } } } catch (Exception ignored) { Log.e(DEBUG_TAG,"Toast in asyncExport.onPostExecute failed with " + ignored.getMessage()); } catch (Error ignored) { Log.e(DEBUG_TAG,"Toast in asyncExport.onPostExecute failed with " + ignored.getMessage()); } } } }.execute(); } /** * Trigger the media scanner to ensure files show up in MTP. * @param context a context to use for communication with the media scanner * @param scanfile directory or file to scan */ @TargetApi(11) private static void triggerMediaScanner(Context context, File scanfile) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) return; // API 11 - lower versions do not have MTP try { String path = scanfile.getCanonicalPath(); Log.i("SavingHelper", "Triggering media scan for " + path); MediaScannerConnection.scanFile(context, new String[] {path}, null, new OnScanCompletedListener() { @Override public void onScanCompleted(String path, Uri uri) { Log.i("SavingHelper", "Media scan completed for " + path + " URI " + uri); } }); } catch (Exception e) { Log.e("SavingHelper", "Exception when triggering media scanner", e); } } public interface Exportable { /** Exports some data to an OutputStream */ void export(OutputStream outputStream) throws Exception; /** @returns the extension to be used for exports */ String exportExtension(); } }