6.1.6 commit

This commit is contained in:
Xilin Jia 2024-07-25 12:49:25 +01:00
parent 109a8b1274
commit 0c9c13dbda
34 changed files with 153 additions and 91 deletions

View File

@ -83,10 +83,10 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings
* History view shows time of last play, and allows filters and sorts
* Multiple queues can be used: 5 queues are provided by default: Default queue, and Queues 1-4
* all queue operations are on the curQueue, which can be set in all episodes list views
* on app startup, the most recently updated queue is set to curQueue
* all queue operations are on the curQueue, which can be set in all episodes list views
* on app startup, the most recently updated queue is set to curQueue
* Every queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played
* Every queue has a bin of past episodes added to the queue
* Every queue has a bin containing past episodes removed from the queue
### Podcast/Episode
@ -127,13 +127,13 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* there are now separate dialogs for inclusive and exclusive filters where filter tokens can be specified independently
* on exclusive dialog, there are optional check boxes "Exclude episodes shorter than" and "Mark excluded episodes played"
### Security and reliability
* Disabled `usesCleartextTraffic`, so that all content transmission is more private and secure
* Settings/Preferences can now be exported and imported
* Play history/progress can be separately exported/imported as Json files
* Downloaded media files can be exported/imported
* Reconsile feature (accessed from Downloads view) is added to ensure downloaded media files are in sync with specs in DB
* There is a setting to disable/enable auto backup of OPML files to Google
* Upon re-install of Podcini, the OPML file previously backed up to Google is not imported automatically but based on user confirmation.

View File

@ -126,8 +126,8 @@ android {
buildConfig true
}
defaultConfig {
versionCode 3020219
versionName "6.1.5"
versionCode 3020220
versionName "6.1.6"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -50,7 +50,6 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
/**
* Searches the podcast directory for a given string.
*
* @param query The search query
* @param scaledLogoSize The size of the logos that are returned by the search query.
* Must be in range 1..256. If the value is out of range, the
@ -82,10 +81,7 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
val devices: List<GpodnetDevice>
/**
* Returns all devices of a given user.
*
*
* This method requires authentication.
*
* @throws GpodnetServiceAuthenticationException If there is an authentication error.
*/
get() {
@ -110,10 +106,7 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
/**
* Configures the device of a given user.
*
*
* This method requires authentication.
*
* @param deviceId The ID of the device that should be configured.
* @throws GpodnetServiceAuthenticationException If there is an authentication error.
*/
@ -147,13 +140,9 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
/**
* Uploads the subscriptions of a specific device.
*
*
* This method requires authentication.
*
* @param deviceId The ID of the device whose subscriptions should be updated.
* @param subscriptions A list of feed URLs containing all subscriptions of the
* device.
* @param subscriptions A list of feed URLs containing all subscriptions of the device.
* @throws IllegalArgumentException If username, deviceId or subscriptions is null.
* @throws GpodnetServiceAuthenticationException If there is an authentication error.
*/
@ -181,16 +170,11 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
/**
* Updates the subscription list of a specific device.
*
*
* This method requires authentication.
*
* @param added Collection of feed URLs of added feeds. This Collection MUST NOT contain any duplicates
* @param removed Collection of feed URLs of removed feeds. This Collection MUST NOT contain any duplicates
* @return a GpodnetUploadChangesResponse. See [GpodnetUploadChangesResponse]
* for details.
* @throws GpodnetServiceException if added or removed contain duplicates or if there
* is an authentication error.
* @return a GpodnetUploadChangesResponse. See [GpodnetUploadChangesResponse] for details.
* @throws GpodnetServiceException if added or removed contain duplicates or if there is an authentication error.
*/
@Throws(GpodnetServiceException::class)
override fun uploadSubscriptionChanges(added: List<String>, removed: List<String>): UploadChangesResponse {
@ -220,13 +204,8 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
}
/**
* Returns all subscription changes of a specific device.
*
*
* This method requires authentication.
*
* @param timestamp A timestamp that can be used to receive all changes since a
* specific point in time.
* Returns all subscription changes of a specific device. This method requires authentication.
* @param timestamp A timestamp that can be used to receive all changes since a specific point in time.
* @throws GpodnetServiceAuthenticationException If there is an authentication error.
*/
@Throws(GpodnetServiceException::class)
@ -254,16 +233,10 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
}
/**
* Updates the episode actions
*
*
* This method requires authentication.
*
* Updates the episode actions. This method requires authentication.
* @param episodeActions Collection of episode actions.
* @return a GpodnetUploadChangesResponse. See [GpodnetUploadChangesResponse]
* for details.
* @throws GpodnetServiceException if added or removed contain duplicates or if there
* is an authentication error.
* @return a GpodnetUploadChangesResponse. See [GpodnetUploadChangesResponse] for details.
* @throws GpodnetServiceException if added or removed contain duplicates or if there is an authentication error.
*/
@Throws(SyncServiceException::class)
override fun uploadEpisodeActions(episodeActions: List<EpisodeAction>): UploadChangesResponse? {
@ -287,7 +260,7 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
val list = JSONArray()
for (i in from until to) {
val episodeAction = episodeActions[i]
val obj = episodeAction!!.writeToJsonObject()
val obj = episodeAction!!.writeToJsonObjectForServer()
if (obj != null) {
obj.put("device", deviceId)
list.put(obj)
@ -312,13 +285,8 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
}
/**
* Returns all subscription changes of a specific device.
*
*
* This method requires authentication.
*
* @param timestamp A timestamp that can be used to receive all changes since a
* specific point in time.
* Returns all subscription changes of a specific device. This method requires authentication.
* @param timestamp A timestamp that can be used to receive all changes since a specific point in time.
* @throws SyncServiceException If there is an authentication error.
*/
@Throws(SyncServiceException::class)
@ -346,9 +314,7 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
}
/**
* Logs in a specific user. This method must be called if any of the methods
* that require authentication is used.
*
* Logs in a specific user. This method must be called if any of the methods that require authentication is used.
* @throws IllegalArgumentException If username or password is null.
*/
@Throws(GpodnetServiceException::class)
@ -508,9 +474,7 @@ class GpodnetService(private val httpClient: OkHttpClient, baseHosturl: String?,
open class GpodnetServiceException : SyncServiceException {
constructor(message: String?) : super(message)
constructor(e: Throwable?) : super(e)
companion object {
private const val serialVersionUID = 1L
}

View File

@ -17,14 +17,12 @@ class EpisodeAction private constructor(builder: Builder) {
/**
* Returns the position (in seconds) at which the client started playback.
*
* @return start position (in seconds)
*/
val started: Int
/**
* Returns the position (in seconds) at which the client stopped playback.
*
* @return stop position (in seconds)
*/
val position: Int
@ -33,7 +31,6 @@ class EpisodeAction private constructor(builder: Builder) {
/**
* Returns the total length of the file in seconds.
*
* @return total length in seconds
*/
val total: Int
@ -82,7 +79,6 @@ class EpisodeAction private constructor(builder: Builder) {
/**
* Returns a JSON object representation of this object.
*
* @return JSON object representation, or null if the object is invalid
*/
fun writeToJsonObject(): JSONObject? {
@ -110,6 +106,32 @@ class EpisodeAction private constructor(builder: Builder) {
return obj
}
/**
* Returns a JSON object representation of this object.
* @return JSON object representation, or null if the object is invalid
*/
fun writeToJsonObjectForServer(): JSONObject? {
val obj = JSONObject()
try {
obj.putOpt("podcast", this.podcast)
obj.putOpt("episode", this.episode)
obj.putOpt("guid", this.guid)
obj.put("action", this.actionString)
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
formatter.timeZone = TimeZone.getTimeZone("UTC")
if (this.timestamp != null) obj.put("timestamp", formatter.format(this.timestamp))
if (this.action == Action.PLAY) {
obj.put("started", this.started)
obj.put("position", this.position)
obj.put("total", this.total)
}
} catch (e: JSONException) {
Log.e(TAG, "writeToJSONObject(): " + e.message)
return null
}
return obj
}
override fun toString(): String {
return ("EpisodeAction{podcast='$podcast', episode='$episode', guid='$guid', action=$action, timestamp=$timestamp, started=$started, position=$position, total=$total playState=$playState isFavorite=$isFavorite}")
}

View File

@ -94,7 +94,7 @@ class NextcloudSyncService(private val httpClient: OkHttpClient, baseHosturl: St
val list = JSONArray()
for (i in from until to) {
val episodeAction = queuedEpisodeActions!![i]
val obj = episodeAction!!.writeToJsonObject()
val obj = episodeAction!!.writeToJsonObjectForServer()
if (obj != null) list.put(obj)
}
val url: HttpUrl.Builder = makeUrl("/index.php/apps/gpoddersync/episode_action/create")

View File

@ -124,7 +124,7 @@ class SynchronizationQueueStorage(context: Context) {
val json = sharedPreferences.getString(QUEUED_EPISODE_ACTIONS, "[]")
try {
val queue = JSONArray(json)
queue.put(action.writeToJsonObject())
queue.put(action.writeToJsonObjectForServer())
sharedPreferences.edit().putString(QUEUED_EPISODE_ACTIONS, queue.toString()).apply()
} catch (jsonException: JSONException) {
jsonException.printStackTrace()

View File

@ -193,7 +193,7 @@ object Queues {
return runOnIOScope {
curQueue.update()
curQueue.episodes.clear()
curQueue.idsBin.addAll(curQueue.episodeIds)
curQueue.idsBinList.addAll(curQueue.episodeIds)
curQueue.episodeIds.clear()
upsert(curQueue) {}
EventFlow.postEvent(FlowEvent.QueueEvent.cleared())
@ -242,7 +242,9 @@ object Queues {
}
if (indicesToRemove.isNotEmpty()) {
for (i in indicesToRemove.indices.reversed()) {
queue.idsBin.add(qItems[indicesToRemove[i]].id)
val id = qItems[indicesToRemove[i]].id
queue.idsBinList.remove(id)
queue.idsBinList.add(id)
qItems.removeAt(indicesToRemove[i])
}
queue.update()
@ -265,7 +267,8 @@ object Queues {
if (q.id == curQueue.id) continue
idsInQueuesToRemove = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
if (idsInQueuesToRemove.isNotEmpty()) {
q.idsBin.addAll(idsInQueuesToRemove)
q.idsBinList.removeAll(idsInQueuesToRemove)
q.idsBinList.addAll(idsInQueuesToRemove)
val qeids = q.episodeIds.minus(idsInQueuesToRemove)
upsert(q) {
it.episodeIds.clear()
@ -278,7 +281,8 @@ object Queues {
val q = curQueue
idsInQueuesToRemove = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
if (idsInQueuesToRemove.isNotEmpty()) {
q.idsBin.addAll(idsInQueuesToRemove)
q.idsBinList.removeAll(idsInQueuesToRemove)
q.idsBinList.addAll(idsInQueuesToRemove)
val qeids = q.episodeIds.minus(idsInQueuesToRemove)
upsert(q) {
it.episodeIds.clear()

View File

@ -18,7 +18,7 @@ import kotlin.coroutines.ContinuationInterceptor
object RealmDB {
private val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
private const val SCHEMA_VERSION_NUMBER = 13L
private const val SCHEMA_VERSION_NUMBER = 14L
private val ioScope = CoroutineScope(Dispatchers.IO)

View File

@ -24,10 +24,7 @@ class PlayQueue : RealmObject {
@Ignore
val episodes: MutableList<Episode> = mutableListOf()
var idsBin: RealmSet<Long> = realmSetOf()
// @Ignore
// val episodesBin: MutableList<Episode> = mutableListOf()
var idsBinList: RealmList<Long> = realmListOf()
fun isInQueue(episode: Episode): Boolean {
return episodeIds.contains(episode.id)

View File

@ -9,12 +9,15 @@ import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.actionbutton.DeleteActionButton
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
@ -35,7 +38,9 @@ import ac.mdiq.podcini.util.event.FlowEvent
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ShareCompat.IntentBuilder
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
@ -50,6 +55,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.*
/**
@ -180,11 +186,58 @@ import java.util.*
R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance())
R.id.downloads_sort -> DownloadsSortDialog().show(childFragmentManager, "SortDialog")
R.id.switch_queue -> SwitchQueueDialog(activity as MainActivity).show()
R.id.reconsile -> reconsile()
else -> return false
}
return true
}
private val nameEpisodeMap: MutableMap<String, Episode> = mutableMapOf()
private val filesRemoved: MutableList<String> = mutableListOf()
private fun reconsile() {
runOnIOScope {
nameEpisodeMap.clear()
episodes.forEach { e ->
var fileUrl = e.media?.fileUrl ?: return@forEach
fileUrl = fileUrl.substring(fileUrl.lastIndexOf('/') + 1)
Logd(TAG, "reconsile: fileUrl: $fileUrl")
nameEpisodeMap[fileUrl] = e
}
val mediaDir = requireContext().getExternalFilesDir("media") ?: return@runOnIOScope
mediaDir.listFiles()?.forEach { file -> traverse(file, mediaDir) }
Logd(TAG, "reconsile: end, episodes missing file: ${nameEpisodeMap.size}")
if (nameEpisodeMap.isNotEmpty()) {
for (e in nameEpisodeMap.values) {
upsertBlk(e) {
e.media?.setfileUrlOrNull(null)
}
}
}
withContext(Dispatchers.Main) {
Toast.makeText(requireContext(), "Episodes reconsiled: ${nameEpisodeMap.size}\nFiles removed: ${filesRemoved.size}", Toast.LENGTH_LONG)
}
}
}
private fun traverse(srcFile: File, srcRootDir: File) {
val relativePath = srcFile.absolutePath.substring(srcRootDir.absolutePath.length+1)
if (srcFile.isDirectory) {
Logd(TAG, "traverse folder title: $relativePath")
val dirFiles = srcFile.listFiles()
dirFiles?.forEach { file -> traverse(file, srcFile) }
} else {
Logd(TAG, "traverse: $srcFile")
val episode = nameEpisodeMap.remove(relativePath)
if (episode == null) {
Logd(TAG, "traverse: error: episode not exist in map: $relativePath")
filesRemoved.add(relativePath)
srcFile.delete()
return
}
Logd(TAG, "traverse found episode: ${episode.title}")
}
}
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
val newRunningDownloads: MutableSet<String> = HashSet()
for (url in event.urls) {

View File

@ -453,7 +453,7 @@ import java.util.*
toolbar.menu?.findItem(R.id.queue_lock)?.setChecked(isQueueLocked)
toolbar.menu?.findItem(R.id.queue_lock)?.setVisible(!keepSorted)
// toolbar.menu.findItem(R.id.switch_queue).setVisible(false)
toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
// toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
}
@UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean {
@ -462,13 +462,11 @@ import java.util.*
R.id.show_bin -> {
showBin = !showBin
if (showBin) {
item.setIcon(R.drawable.ic_delete)
item.setIcon(R.drawable.playlist_play)
speedDialView.addActionItem(addToQueueActionItem)
swipeActions.detach()
} else {
item.setIcon(R.drawable.trash_can_arrow_up_solid)
speedDialView.removeActionItem(addToQueueActionItem)
swipeActions.attachTo(recyclerView)
}
loadItems(false)
}
@ -486,8 +484,9 @@ import java.util.*
conDialog.createNewDialog().show()
}
R.id.clear_bin -> {
curQueue.idsBin.clear()
curQueue.idsBinList.clear()
upsertBlk(curQueue) {}
if (showBin) loadItems(false)
}
R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance())
// R.id.switch_queue -> SwitchQueueDialog(activity as MainActivity).show()
@ -618,8 +617,8 @@ import java.util.*
if (queueItems.isEmpty()) emptyView.hide()
queueItems.clear()
if (showBin) {
queueItems.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.idsBin)
.find().sortedBy { curQueue.idsBin.indexOf(it.id) }))
queueItems.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.idsBinList)
.find().sortedBy { curQueue.idsBinList.indexOf(it.id) }))
} else {
curQueue.episodes.clear()
curQueue.episodes.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.episodeIds)

View File

@ -79,7 +79,7 @@ import kotlin.math.min
override fun updateToolbar() {
binding.toolbar.menu.findItem(R.id.episodes_sort).setVisible(false)
binding.toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
// binding.toolbar.menu.findItem(R.id.refresh_item).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_search).setVisible(false)
binding.toolbar.menu.findItem(R.id.action_favorites).setVisible(false)
binding.toolbar.menu.findItem(R.id.filter_items).setVisible(false)

View File

@ -17,10 +17,16 @@
custom:showAsAction="always" />
<item
android:id="@+id/refresh_item"
android:title="@string/refresh_label"
android:menuCategory="container"
android:id="@+id/reconsile"
android:title="@string/reconsile_label"
app:showAsAction="never" />
<!-- <item-->
<!-- android:id="@+id/refresh_item"-->
<!-- android:title="@string/refresh_label"-->
<!-- android:menuCategory="container"-->
<!-- app:showAsAction="never" />-->
<item
android:id="@+id/downloads_sort"
android:title="@string/sort" />

View File

@ -29,11 +29,11 @@
android:title="@string/sort"
custom:showAsAction="ifRoom" />
<item
android:id="@+id/refresh_item"
android:title="@string/refresh_label"
android:menuCategory="container"
custom:showAsAction="never" />
<!-- <item-->
<!-- android:id="@+id/refresh_item"-->
<!-- android:title="@string/refresh_label"-->
<!-- android:menuCategory="container"-->
<!-- custom:showAsAction="never" />-->
<item
android:id="@+id/switch_queue"

View File

@ -4,7 +4,7 @@
<item
android:id="@+id/show_bin"
android:icon="@drawable/trash_can_arrow_up_solid"
android:icon="@drawable/ic_history"
custom:showAsAction="ifRoom"
android:title="@string/show_bin_label"/>
@ -14,11 +14,11 @@
custom:showAsAction="ifRoom"
android:title="@string/search_label"/>
<item
android:id="@+id/refresh_item"
android:title="@string/refresh_label"
android:menuCategory="container"
custom:showAsAction="never" />
<!-- <item-->
<!-- android:id="@+id/refresh_item"-->
<!-- android:title="@string/refresh_label"-->
<!-- android:menuCategory="container"-->
<!-- custom:showAsAction="never" />-->
<item
android:id="@+id/queue_lock"

View File

@ -119,6 +119,7 @@
<string name="error_label">Error</string>
<string name="error_msg_prefix">An error occurred:</string>
<string name="refresh_label">Refresh</string>
<string name="reconsile_label">Reconsile</string>
<string name="chapters_label">Chapters</string>
<string name="no_chapters_label">No chapters</string>
<string name="chapter_duration">Duration: %1$s</string>

View File

@ -1,3 +1,11 @@
# 6.1.6
* enabled swipe actions in Queue bin view (same actions as in Queue)
* both icons of show bin and back to queue are changed to be more intuitive
* bin items are sorted based on the update time
* added a reconsile feature in Downloads view that verifies episodes' download status with media files in system and performs cleanup
* likely fixed syncing with nextcloud and gpoddernet servers.
# 6.1.5
* minor adjustments on FeedInfo page, especially for handling long feed title

View File

@ -0,0 +1,8 @@
Version 6.1.6 brings several changes:
* enabled swipe actions in Queue bin view (same actions as in Queue)
* both icons of show bin and back to queue are changed to be more intuitive
* bin items are sorted based on the update time
* added a reconsile feature in Downloads view that verifies episodes' download status with media files in system and performs cleanup
* likely fixed syncing with nextcloud and gpoddernet servers.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 272 KiB