Commit a8fd14b2 authored by Sebastian Kaspari's avatar Sebastian Kaspari
Browse files

Closes #2216: feature-session-bundling: Save state outside of database.

parent 7809908e
......@@ -38,6 +38,7 @@ dependencies {
testImplementation project(':support-test')
testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.testing_junit
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_mockito
......
......@@ -4,21 +4,35 @@
package mozilla.components.browser.session.ext
import android.content.Context
import android.util.AtomicFile
import androidx.test.core.app.ApplicationProvider
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.session.storage.SnapshotSerializer
import mozilla.components.browser.session.storage.getFileForEngine
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSessionState
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.json.JSONObject
import org.junit.Assert
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.RuntimeEnvironment
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class AtomicFileKtTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Test
fun `writeSnapshot - Fails write on IOException`() {
val file: AtomicFile = mock()
......@@ -47,7 +61,7 @@ class AtomicFileKtTest {
@Test
fun `readSnapshot - Returns null on corrupt JSON`() {
val file = getFileForEngine(RuntimeEnvironment.application, engine = mock())
val file = getFileForEngine(context, engine = mock())
val stream = file.startWrite()
stream.bufferedWriter().write("{ name: 'Foo")
......@@ -56,4 +70,64 @@ class AtomicFileKtTest {
val snapshot = file.readSnapshot(engine = mock(), serializer = SnapshotSerializer())
assertNull(snapshot)
}
@Test
fun `Read snapshot should contain sessions of written snapshot`() {
val session1 = Session("http://mozilla.org", id = "session1")
val session2 = Session("http://getpocket.com", id = "session2")
val session3 = Session("http://getpocket.com", id = "session3")
session3.parentId = "session1"
val engineSessionState = object : EngineSessionState {
override fun toJSON() = JSONObject()
}
val engineSession = Mockito.mock(EngineSession::class.java)
Mockito.`when`(engineSession.saveState()).thenReturn(engineSessionState)
val engine = Mockito.mock(Engine::class.java)
Mockito.`when`(engine.name()).thenReturn("gecko")
Mockito.`when`(engine.createSession()).thenReturn(Mockito.mock(EngineSession::class.java))
Mockito.`when`(engine.createSessionState(any())).thenReturn(engineSessionState)
// Engine session just for one of the sessions for simplicity.
val sessionsSnapshot = SessionManager.Snapshot(
sessions = listOf(
SessionManager.Snapshot.Item(session1),
SessionManager.Snapshot.Item(session2),
SessionManager.Snapshot.Item(session3)
),
selectedSessionIndex = 0
)
val file = AtomicFile(File.createTempFile(
UUID.randomUUID().toString(),
UUID.randomUUID().toString()))
file.writeSnapshot(sessionsSnapshot)
// Read it back
val restoredSnapshot = file.readSnapshot(engine)
Assert.assertNotNull(restoredSnapshot)
Assert.assertEquals(3, restoredSnapshot!!.sessions.size)
Assert.assertEquals(0, restoredSnapshot.selectedSessionIndex)
Assert.assertEquals(session1, restoredSnapshot.sessions[0].session)
Assert.assertEquals(session1.url, restoredSnapshot.sessions[0].session.url)
Assert.assertEquals(session1.id, restoredSnapshot.sessions[0].session.id)
assertNull(restoredSnapshot.sessions[0].session.parentId)
Assert.assertEquals(session2, restoredSnapshot.sessions[1].session)
Assert.assertEquals(session2.url, restoredSnapshot.sessions[1].session.url)
Assert.assertEquals(session2.id, restoredSnapshot.sessions[1].session.id)
assertNull(restoredSnapshot.sessions[1].session.parentId)
Assert.assertEquals(session3, restoredSnapshot.sessions[2].session)
Assert.assertEquals(session3.url, restoredSnapshot.sessions[2].session.url)
Assert.assertEquals(session3.id, restoredSnapshot.sessions[2].session.id)
Assert.assertEquals("session1", restoredSnapshot.sessions[2].session.parentId)
val restoredEngineSession = restoredSnapshot.sessions[0].engineSessionState
Assert.assertNotNull(restoredEngineSession)
}
}
\ No newline at end of file
......@@ -31,6 +31,10 @@ android {
packagingOptions {
exclude 'META-INF/proguard/androidx-annotations.pro'
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
dependencies {
......@@ -38,6 +42,7 @@ dependencies {
implementation project(':browser-session')
implementation project(':support-ktx')
implementation project(':support-base')
implementation Dependencies.kotlin_stdlib
......
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "73c5850df651b3973eaac9cadd78b7ad",
"entities": [
{
"tableName": "bundles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `saved_at` INTEGER NOT NULL, `urls` TEXT NOT NULL, `file` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "savedAt",
"columnName": "saved_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "file",
"columnName": "file",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"73c5850df651b3973eaac9cadd78b7ad\")"
]
}
}
\ No newline at end of file
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.session.bundling
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory
import android.arch.persistence.room.Room
import android.arch.persistence.room.testing.MigrationTestHelper
import android.content.Context
import android.util.AttributeSet
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSessionState
import mozilla.components.concept.engine.EngineView
import mozilla.components.concept.engine.Settings
import mozilla.components.feature.session.bundling.db.BundleDatabase
import mozilla.components.feature.session.bundling.db.Migrations
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
private const val MIGRATION_TEST_DB = "migration-test"
class OnDeviceSessionBundleStorageTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private lateinit var database: BundleDatabase
private lateinit var storage: SessionBundleStorage
private lateinit var engine: Engine
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
BundleDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Before
fun setUp() {
engine = FakeEngine()
database = Room.inMemoryDatabaseBuilder(context, BundleDatabase::class.java).build()
storage = SessionBundleStorage(context, engine, Pair(5L, TimeUnit.MINUTES))
storage.databaseInitializer = {
database
}
}
@After
fun tearDown() {
database.close()
}
@Test
fun testStorageInteraction() {
assertNull(storage.restore())
val firstSnapshot = SessionManager.Snapshot(listOf(
SessionManager.Snapshot.Item(Session("https://www.mozilla.org")),
SessionManager.Snapshot.Item(Session("https://www.firefox.com"))
), selectedSessionIndex = 1)
storage.save(firstSnapshot)
assertNotNull(storage.current())
val restoredBundle = storage.restore()
assertNotNull(restoredBundle!!)
assertEquals(2, restoredBundle.urls.size)
assertEquals("https://www.mozilla.org", restoredBundle.urls[0])
assertEquals("https://www.firefox.com", restoredBundle.urls[1])
val restoredSnapshot = restoredBundle.restoreSnapshot()
assertNotNull(restoredSnapshot!!)
assertFalse(restoredSnapshot.isEmpty())
assertEquals(2, restoredSnapshot.sessions.size)
assertEquals("https://www.mozilla.org", restoredSnapshot.sessions[0].session.url)
assertEquals("https://www.firefox.com", restoredSnapshot.sessions[1].session.url)
assertEquals(1, restoredSnapshot.selectedSessionIndex)
}
@Test
fun migrate1to2() {
helper.createDatabase(MIGRATION_TEST_DB, 1).apply {
execSQL("INSERT INTO " +
"bundles " +
"(id, state, saved_at, urls) " +
"VALUES " +
"(1, 'def', 750, 'https://www.mozilla.org')")
}
val db = helper.runMigrationsAndValidate(
MIGRATION_TEST_DB, 2, true, Migrations.migration_1_2)
db.query("SELECT COUNT(*) as count FROM bundles").use { cursor ->
assertEquals(1, cursor.columnCount)
assertEquals(1, cursor.count)
cursor.moveToFirst()
assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("count")))
}
}
private class FakeEngine : Engine {
override fun createView(context: Context, attrs: AttributeSet?): EngineView {
throw NotImplementedError()
}
override fun createSession(private: Boolean): EngineSession {
throw NotImplementedError()
}
override fun createSessionState(json: JSONObject): EngineSessionState {
return object : EngineSessionState {
override fun toJSON(): JSONObject {
throw NotImplementedError()
}
}
}
override fun name(): String {
return "fake"
}
override fun speculativeConnect(url: String) {
throw NotImplementedError()
}
override val settings: Settings
get() = throw NotImplementedError()
}
}
......@@ -17,7 +17,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SessionBundleStorageTest {
class BundleDaoTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
......@@ -39,19 +39,16 @@ class SessionBundleStorageTest {
fun testInsertingAndReadingBundles() {
bundleDao.insertBundle(BundleEntity(
id = null,
state = "",
savedAt = 100,
urls = UrlList(listOf("https://www.mozilla.org"))))
bundleDao.insertBundle(BundleEntity(
id = null,
state = "",
savedAt = 200,
urls = UrlList(listOf("https://www.firefox.com"))))
bundleDao.insertBundle(BundleEntity(
id = null,
state = "",
savedAt = 50,
urls = UrlList(listOf("https://getpocket.com"))))
......
......@@ -4,8 +4,8 @@
package mozilla.components.feature.session.bundling
import android.support.annotation.WorkerThread
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.Engine
/**
* A bundle of sessions and their state.
......@@ -30,5 +30,6 @@ interface SessionBundle {
* Restore a [SessionManager.Snapshot] from this bundle. The returned snapshot can be used with [SessionManager] to
* restore the sessions and their state.
*/
fun restoreSnapshot(engine: Engine): SessionManager.Snapshot?
@WorkerThread
fun restoreSnapshot(): SessionManager.Snapshot?
}
......@@ -14,11 +14,15 @@ import android.support.annotation.VisibleForTesting
import android.support.annotation.WorkerThread
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.session.ext.writeSnapshot
import mozilla.components.browser.session.storage.AutoSave
import mozilla.components.concept.engine.Engine
import mozilla.components.feature.session.bundling.adapter.SessionBundleAdapter
import mozilla.components.feature.session.bundling.db.BundleDatabase
import mozilla.components.feature.session.bundling.db.BundleEntity
import mozilla.components.feature.session.bundling.ext.toBundleEntity
import mozilla.components.support.ktx.java.io.truncateDirectory
import java.io.File
import java.lang.IllegalArgumentException
import java.util.concurrent.TimeUnit
......@@ -30,7 +34,8 @@ import java.util.concurrent.TimeUnit
*/
@Suppress("TooManyFunctions")
class SessionBundleStorage(
context: Context,
private val context: Context,
private val engine: Engine,
internal val bundleLifetime: Pair<Long, TimeUnit>
) : AutoSave.Storage {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
......@@ -54,7 +59,7 @@ class SessionBundleStorage(
.getLastBundle(since)
.also { lastBundle = it }
return entity?.let { SessionBundleAdapter(it) }
return entity?.let { SessionBundleAdapter(context, engine, it) }
}
/**
......@@ -91,6 +96,8 @@ class SessionBundleStorage(
}
bundle.actual.let { database.bundleDao().deleteBundle(it) }
bundle.actual.stateFile(context, engine).delete()
}
/**
......@@ -101,6 +108,9 @@ class SessionBundleStorage(
fun removeAll() {
lastBundle = null
database.clearAllTables()
getStateDirectory(context)
.truncateDirectory()
}
/**
......@@ -109,7 +119,7 @@ class SessionBundleStorage(
*/
@Synchronized
fun current(): SessionBundle? {
return lastBundle?.let { SessionBundleAdapter(it) }
return lastBundle?.let { SessionBundleAdapter(context, engine, it) }
}
/**
......@@ -146,7 +156,7 @@ class SessionBundleStorage(
.bundleDao()
.getBundles(since, limit)
) { list ->
list.map { SessionBundleAdapter(it) }
list.map { SessionBundleAdapter(context, engine, it) }
}
}
......@@ -166,7 +176,7 @@ class SessionBundleStorage(
return database
.bundleDao()
.getBundlesPaged(since)
.map { entity -> SessionBundleAdapter(entity) }
.map { entity -> SessionBundleAdapter(context, engine, entity) }
}
/**
......@@ -203,7 +213,12 @@ class SessionBundleStorage(
return
}
val bundle = snapshot.toBundleEntity().also { lastBundle = it }
val bundle = snapshot.toBundleEntity().also {
lastBundle = it
}
bundle.stateFile(context, engine).writeSnapshot(snapshot)
bundle.id = database.bundleDao().insertBundle(bundle)
}
......@@ -214,8 +229,10 @@ class SessionBundleStorage(
if (snapshot.isEmpty()) {
// If this snapshot is empty then instead of saving an empty bundle: Remove the bundle. Otherwise
// we end up with empty bundles to restore and that is not helpful at all.
remove(SessionBundleAdapter(bundle))
remove(SessionBundleAdapter(context, engine, bundle))
} else {
bundle.stateFile(context, engine).writeSnapshot(snapshot)
bundle.updateFrom(snapshot)
database.bundleDao().updateBundle(bundle)
}
......@@ -223,4 +240,12 @@ class SessionBundleStorage(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun now() = System.currentTimeMillis()
companion object {
internal fun getStateDirectory(context: Context): File {
return File(context.filesDir, "mozac.feature.session.bundling").apply {
mkdirs()
}
}
}
}
......@@ -4,21 +4,22 @@
package mozilla.components.feature.session.bundling.adapter
import android.content.Context
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.session.storage.SnapshotSerializer
import mozilla.components.browser.session.ext.readSnapshot
import mozilla.components.concept.engine.Engine
import mozilla.components.feature.session.bundling.SessionBundle
import mozilla.components.feature.session.bundling.db.BundleEntity
import org.json.JSONException
/**
* Adapter implementation to make a [BundleEntity] accessible as [SessionBundle] without "leaking" the underlying
* entity class.
*/
internal class SessionBundleAdapter(
internal val context: Context,
private val engine: Engine,
internal val actual: BundleEntity
) : SessionBundle {
override val id: Long?
get() = actual.id
......@@ -31,11 +32,8 @@ internal class SessionBundleAdapter(
/**
* Re-create the [SessionManager.Snapshot] from the state saved in the database.
*/
override fun restoreSnapshot(engine: Engine): SessionManager.Snapshot? {
return try {
SnapshotSerializer().fromJSON(engine, actual.state)
} catch (e: JSONException) {
null
}
override fun restoreSnapshot(): SessionManager.Snapshot? {
return actual.stateFile(context, engine)
.readSnapshot(engine)
}
}
......@@ -4,17 +4,19 @@
package mozilla.components.feature.session.bundling.db
import android.arch.persistence.db.SupportSQLiteDatabase
import android.arch.persistence.room.Database