SQLiteAssetPortal.java

package net.zer0bandwidth.android.lib.database;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper ;
import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * Provides a {@link SQLiteOpenHelper} implementation which manages a read-only
 * database that is loaded from an asset file.
 *
 * <p>See <a href="https://blog.reigndesign.com/blog/using-your-own-sqlite-database-in-android-applications/">
 * Using Your Own SQLite Database in Android Applications</a> for the basis of
 * this implementation.</p>
 *
 * <h4>Example</h4>
 *
 * <pre>
 * public class MyDB extends SQLiteAssetPortal
 * {
 *     protected static final String DB_NAME = "mydb" ;
 *
 *     /**
 *      * Rather than using this to represent the version of the database
 *      * schema, use this constant to represent the version of the asset. Thus,
 *      * whenever new content is added to the asset, the app will know to
 *      * overwrite its database with the contents of the new asset. See the
 *      * example of onUpgrade() below.
 *     {@literal *}/
 *     protected static final int DB_VERSION = 2 ;
 *
 *     /** Filename of the asset containing the static database instance. {@literal *}/
 *     protected static final String DB_SOURCE_ASSET = "mydb.v2.db" ;
 *
 *     public MyDB( Context ctx )
 *     { super( ctx, DB_NAME, null, DB_VERSION ) ; }
 *
 *    {@literal @}Override
 *     public abstract String getAssetName()
 *     { return DB_SOURCE_ASSET ; }
 * }
 * </pre>
 *
 * @since zer0bandwidth-net/android 0.1.4 (#34)
 */
public abstract class SQLiteAssetPortal
extends SQLitePortal
{
	public static final String LOG_TAG =
			SQLiteAssetPortal.class.getSimpleName() ;

/// Inner Classes //////////////////////////////////////////////////////////////

	/**
	 * Allows the {@link SQLiteAssetPortal} to create a persistent connection to
	 * its underlying database on a background thread.
	 * @since zer0bandwidth-net/android 0.1.4 (#34)
	 */
	protected class ConnectionTask
	extends SQLitePortal.ConnectionTask
	implements Runnable
	{
		/**
		 * A reference back to the {@link SQLiteAssetPortal} that needs the
		 * connection.
		 */
		protected SQLiteAssetPortal m_dbh = SQLiteAssetPortal.this ;

		protected ConnectionTask( ConnectionListener l )
		{ m_listener = l ; }

		@Override
		public void run()
		{
			m_dbh.m_db = null ;
			m_dbh.m_bIsConnected = false ;
			try { m_dbh.m_db = m_dbh.getReadableDatabase() ; }
			catch( Exception x )
			{ Log.e( LOG_TAG, "Could not establish initial connection." ) ; }
			if( m_dbh.m_bNeedsCopy ) // set by onCreate() / onUpgrade()
			{ // Close the database connection, copy from asset, and reopen DB.
				m_dbh.close() ;
				m_dbh.copyFromAsset() ;
				try
				{
					m_dbh.m_db = m_dbh.getReadableDatabase() ;
					// Explicitly override the copy flag after this second call,
					// to avoid spurious re-copying of the DB. This must be done
					// because onCreate() can still be called by Android as part
					// of the SQLite DB connection life cycle, and our override
					// would blindly set the copy flag to true.
					m_dbh.m_bNeedsCopy = false ;
				}
				catch( Exception x )
				{
					Log.e( LOG_TAG,
							"Could not connect after copying from asset." ) ;
				}
			}
			m_dbh.m_bIsConnected = ( m_dbh.m_db != null ) ;
			if( m_dbh.m_bIsConnected && m_listener != null )
				m_listener.onDatabaseConnected(m_dbh) ;
		}
	}

/// Instance Members ///////////////////////////////////////////////////////////

	/**
	 * The only method in which the old and new schema versions are exposed is
	 * in {@link #onUpgrade}; thus, that method will set this indicator flag
	 * so that the consumer can then explicitly invoke {@link #copyFromAsset}.
	 */
	protected boolean m_bNeedsCopy = false ;

/// Inherited Constructors (must duplicate here for descendants) ///////////////

	public SQLiteAssetPortal( Context ctx, String sDatabaseName,
							  SQLiteDatabase.CursorFactory cf, int nVersion )
	{ super( ctx, sDatabaseName, cf, nVersion ) ; }

/// android.database.sqlite.SQLiteOpenHelper ///////////////////////////////////

	/**
	 * If the database did not previously exist, then we need to copy it from
	 * the asset.
	 *
	 * <p>Note that, since {@link ConnectionTask} executes
	 * {@link SQLiteOpenHelper#getReadableDatabase()} twice, it is possible that
	 * this method will be called spuriously during the second invocation.
	 * However, since the task immediately resets {@link #m_bNeedsCopy} to
	 * {@code false} after this, this has no negative consequence, other than
	 * the time wasted on the method call.</p>
	 */
	@Override
	public final void onCreate( SQLiteDatabase db )
	{
		Log.d( LOG_TAG, "onCreate() called; asset may be copied." ) ;
		m_bNeedsCopy = true ;
	}

	/**
	 * Copy the database asset only if an upgrade is needed.
	 */
	@Override
	public final void onUpgrade( SQLiteDatabase db, int nOld, int nNew )
	{
		m_bNeedsCopy = ( nOld < nNew ) ;
		if( m_bNeedsCopy )
		{
			Log.d( LOG_TAG, (new StringBuilder())
					.append( "onUpdate(): Asset should be copied; old version [" )
					.append( nOld ).append( "] is less than new version [" )
					.append( nNew ).append( "]." )
					.toString()
				);
		}
	}

/// net.zer0bandwidth.android.lib.database.SQLitePortal ////////////////////////

	/**
	 * Forces the connection to be read-only, and uses the descendant version of
	 * {@link ConnectionTask} to open, and then copy, the database.
	 * @param bReadOnly placebo - always overridden as {@code true} in this
	 *  version of the method
	 * @param l the listener for the "on connected" callback
	 * @return (fluid)
	 */
	@Override
	public synchronized SQLiteAssetPortal openDB( boolean bReadOnly, ConnectionListener l )
	{
		m_bReadOnly = true ;     // Asset-copied databases are always read-only.
		(new SQLiteAssetPortal.ConnectionTask(l)).runInBackground() ;
		return this ;
	}

/// Database from Assets ///////////////////////////////////////////////////////

	/**
	 * Descendant classes must implement this method to provide the name of the
	 * asset from which the database will be replicated.
	 * @return the name of the asset which will be copied as the app's database
	 */
	public abstract String getAssetName() ;

	/**
	 * If indicated by the flag set during connection, this method overwrites
	 * the portal's database with the contents of a static asset packaged in the
	 * APK.
	 * @return {@code true} if the asset was successfully copied; {@code false}
	 *  otherwise
	 */
	protected boolean copyFromAsset()
	{
		final String sAssetFileName = this.getAssetName() ;
		boolean bSuccess = true ;
		InputStream in = null ;
		OutputStream out = null ;
		String sDatabaseName = this.getDatabaseName() ;
		try
		{
			String sDatabaseFile = this.getPathToDatabaseFile() ;
			if( this.databaseExists() )
			{
				File fOld = new File( sDatabaseFile ) ;
				if( fOld.delete() )
					Log.i( LOG_TAG, "Deleted previous database file!" ) ;
			}
			in = m_ctx.getAssets().open( sAssetFileName ) ;
			out = new FileOutputStream( sDatabaseFile ) ;
			byte[] ayBuffer = new byte[1024] ;
			int nLength ;
			Log.d( LOG_TAG, (new StringBuilder())
					.append( "Copying database from asset [" )
					.append( sAssetFileName )
					.append( "] to database [" )
					.append( sDatabaseName )
					.append( "]..." )
					.toString()
				);
			while( ( nLength = in.read(ayBuffer) ) > 0 )
				out.write( ayBuffer, 0, nLength ) ;
		}
		catch( IOException iox )
		{
			Log.e( LOG_TAG, (new StringBuilder())
					.append( "Could not copy asset [" )
					.append( sAssetFileName )
					.append( "] to database [" )
					.append( sDatabaseName )
					.append( "]:" )
					.toString()
				, iox );
			bSuccess = false ;
		}
		finally
		{ // Ensure the input/output streams are closed.
			try { if( in != null ) in.close() ; }
			catch( IOException ioxCloseInput )
			{
				Log.e( LOG_TAG, "Could not close input stream!",
						ioxCloseInput ) ;
			}
			if( out != null )
			{
				try { out.flush() ;  out.close() ; }
				catch( IOException ioxCloseOutput )
				{
					Log.e( LOG_TAG,
							"Could not close output stream!",
							ioxCloseOutput
						);
				}
			}
		}
		return bSuccess ;
	}
}