Conversation
|
@coderabbitai review |
WalkthroughThis change introduces a comprehensive downloads management feature to the Android app. It adds a new DownloadsActivity, associated layouts, a ViewModel for download state management, a RecyclerView adapter, click handling, and relevant string resources. The main activity is updated to provide access to the downloads screen, and player initialization parameters are updated. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant MainActivity
participant DownloadsActivity
participant DownloadViewModel
participant DownloadAdapter
participant DownloadTracker
User->>MainActivity: Clicks "View Downloads"
MainActivity->>DownloadsActivity: Starts activity
DownloadsActivity->>DownloadViewModel: Observes downloads LiveData
DownloadViewModel->>DownloadTracker: Registers listener, loads downloads
DownloadTracker-->>DownloadViewModel: Notifies on downloads update
DownloadViewModel-->>DownloadsActivity: Updates downloads LiveData
DownloadsActivity->>DownloadAdapter: Submits new list
User->>DownloadsActivity: Clicks on a download item
DownloadsActivity->>DownloadAdapter: Gets item state
DownloadsActivity->>User: Shows popup menu (Pause/Resume/Delete/Cancel)
User->>DownloadsActivity: Selects action
DownloadsActivity->>DownloadViewModel: Calls pause/resume/remove
DownloadViewModel->>DownloadTracker: Performs action
DownloadTracker-->>DownloadViewModel: Notifies on state change
Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (11)
app/src/main/java/com/tpstreams/player/MainActivity.kt (1)
26-26: Move SDK initialisation to anApplicationclass
TPStreamsPlayer.init("9q94nm")is invoked every timeMainActivityis recreated (rotation, multi-window, process restart).
Initialise once in a customApplicationsubclass instead:class TPStreamsApp : Application() { override fun onCreate() { super.onCreate() TPStreamsPlayer.init("9q94nm") } }Don’t forget to register it in the manifest.
app/src/main/res/layout/activity_main.xml (1)
19-27: Add accessibility and style attributes to the new buttonFor consistency and accessibility:
android:id="@+id/downloads_button" +style="?attr/buttonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/view_downloads" +android:contentDescription="@string/view_downloads"This reuses the app’s default button style and provides an explicit content description for screen-reader users.
app/src/main/AndroidManifest.xml (1)
28-31: Use string resources & add legacy parent meta-dataHard-coding
"Downloads"breaks localisation and lints. Also, for API < 17 add the legacy parent meta-data tag.-<activity - android:name=".DownloadsActivity" - android:exported="false" - android:label="Downloads" - android:parentActivityName=".MainActivity" /> +<activity + android:name=".DownloadsActivity" + android:exported="false" + android:label="@string/title_downloads" + android:parentActivityName=".MainActivity"> + <meta-data + android:name="android.support.PARENT_ACTIVITY" + android:value=".MainActivity" /> +</activity>app/src/main/res/values/strings.xml (1)
19-19: Explicitly declare the placeholder format fordownload_sizeAdd
format="string"to make lint happy and avoid runtime formatting issues with non-string values.- <string name="download_size">Size: %1$s</string> + <string name="download_size" format="string">Size: %1$s</string>app/src/main/res/layout/item_download.xml (1)
38-47: Set an explicit max value on the progress barRelying on the default
max=100is brittle; being explicit improves readability and prevents surprises if the default ever changes.- <ProgressBar + <ProgressBar android:id="@+id/download_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:max="100" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/asset_id_text" tools:progress="65" />app/src/main/res/layout/activity_downloads.xml (1)
31-51: Potential overlap between empty view and progress barBoth
empty_viewandprogress_barare centered below the toolbar and could occupy the same space leading to visual flicker when toggling visibility.
Consider putting them in a small wrapperFrameLayout(or mutually exclusiveViewSwitcher) so only one child is ever visible at a time.app/src/main/java/com/tpstreams/player/DownloadsActivity.kt (1)
45-53: Duplicate empty-state checks
checkEmptyState()is called both here and from the adapter observer. One is sufficient; keeping both is redundant and makes the code harder to reason about.app/src/main/java/com/tpstreams/player/RecyclerItemClickListener.kt (1)
37-44: Returnfalseafter delegating to keep RecyclerView scroll behaviour intactReturning
truefromonInterceptTouchEventconsumes the touch event, which can blockRecyclerViewscrolling if the tap is slightly dragged.
Change the logic to:if (childView != null && listener != null && gestureDetector.onTouchEvent(e)) { listener.onItemClick(childView, view.getChildAdapterPosition(childView)) } return falseThis lets the click propagate while still notifying the listener.
app/src/main/java/com/tpstreams/player/DownloadAdapter.kt (3)
34-35: Hard-coded UI strings break localisation & accessibilityStrings such as
"Asset ID: "and the state literals are embedded directly in code.
Move them intostrings.xmland fetch viaitemView.context.getString(...)to enable i18n/RTL, testing, and easy copy editing.-assetIdText.text = "Asset ID: ${downloadItem.assetId}" +assetIdText.text = itemView.context.getString( + R.string.download_asset_id_format, + downloadItem.assetId +)Do the same for each state in
getStateString, e.g.:-Download.STATE_COMPLETED -> "COMPLETED" +Download.STATE_COMPLETED -> context.getString(R.string.state_completed)Also applies to: 50-59
14-23: Enable stable IDs for smoother list animations
ListAdapterdiffing is optimal, but without stable IDs the layout system must re-bind on every change, causing flickers during rapid progress updates.class DownloadAdapter : ListAdapter<DownloadItem, DownloadAdapter.DownloadViewHolder>( DownloadDiffCallback() ) { + init { setHasStableIds(true) } + + override fun getItemId(position: Int): Long = + getItem(position).assetId.hashCode().toLong()
49-60: Avoid allocating strings repeatedly ingetStateStringMapping every bind call creates new
Stringinstances.
Return resource IDs and resolve once, or cache an immutable map.Consider:
private val stateLabels = mapOf( Download.STATE_COMPLETED to R.string.state_completed, ... ) @StringRes private fun stateRes(state: Int) = stateLabels[state] ?: R.string.state_unknown
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (11)
app/src/main/AndroidManifest.xml(1 hunks)app/src/main/java/com/tpstreams/player/DownloadAdapter.kt(1 hunks)app/src/main/java/com/tpstreams/player/DownloadViewModel.kt(1 hunks)app/src/main/java/com/tpstreams/player/DownloadsActivity.kt(1 hunks)app/src/main/java/com/tpstreams/player/MainActivity.kt(2 hunks)app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt(1 hunks)app/src/main/java/com/tpstreams/player/RecyclerItemClickListener.kt(1 hunks)app/src/main/res/layout/activity_downloads.xml(1 hunks)app/src/main/res/layout/activity_main.xml(1 hunks)app/src/main/res/layout/item_download.xml(1 hunks)app/src/main/res/values/strings.xml(1 hunks)
| private val _isLoading = MutableLiveData<Boolean>() | ||
| val isLoading: LiveData<Boolean> = _isLoading |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Initialize _isLoading with a default value to avoid null emissions
MutableLiveData<Boolean>() starts out as null, so observers must defensively handle the first null emission.
Given _isLoading is effectively a boolean flag, initialise it to false so the UI never has to deal with an unexpected null.
-private val _isLoading = MutableLiveData<Boolean>()
+private val _isLoading = MutableLiveData(false)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private val _isLoading = MutableLiveData<Boolean>() | |
| val isLoading: LiveData<Boolean> = _isLoading | |
| private val _isLoading = MutableLiveData(false) | |
| val isLoading: LiveData<Boolean> = _isLoading |
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadViewModel.kt around lines 17
to 18, the MutableLiveData _isLoading is not initialized with a default value,
causing it to emit null initially. Fix this by initializing _isLoading with
false to ensure observers always receive a non-null Boolean value and the UI
does not have to handle null cases.
| fun loadDownloads() { | ||
| _isLoading.value = true | ||
| val downloadItems = downloadTracker.getAllDownloadItems() | ||
| _downloads.value = downloadItems | ||
| _isLoading.value = false |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Off-load loadDownloads() to a background thread
downloadTracker.getAllDownloadItems() can touch disk / network and is executed on the main thread, risking UI jank.
Run the fetch on Dispatchers.IO and post the result back:
-fun loadDownloads() {
- _isLoading.value = true
- val downloadItems = downloadTracker.getAllDownloadItems()
- _downloads.value = downloadItems
- _isLoading.value = false
-}
+fun loadDownloads() = viewModelScope.launch {
+ _isLoading.postValue(true)
+ val downloadItems = withContext(Dispatchers.IO) {
+ downloadTracker.getAllDownloadItems()
+ }
+ _downloads.postValue(downloadItems)
+ _isLoading.postValue(false)
+}(remember to add import androidx.lifecycle.viewModelScope and coroutine deps if not already present).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fun loadDownloads() { | |
| _isLoading.value = true | |
| val downloadItems = downloadTracker.getAllDownloadItems() | |
| _downloads.value = downloadItems | |
| _isLoading.value = false | |
| // remember to import: | |
| // import androidx.lifecycle.viewModelScope | |
| // import kotlinx.coroutines.Dispatchers | |
| // import kotlinx.coroutines.withContext | |
| fun loadDownloads() = viewModelScope.launch { | |
| _isLoading.postValue(true) | |
| val downloadItems = withContext(Dispatchers.IO) { | |
| downloadTracker.getAllDownloadItems() | |
| } | |
| _downloads.postValue(downloadItems) | |
| _isLoading.postValue(false) | |
| } |
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadViewModel.kt around lines 32
to 36, the loadDownloads() function performs disk or network operations on the
main thread, which can cause UI jank. To fix this, wrap the call to
downloadTracker.getAllDownloadItems() inside a coroutine launched on
viewModelScope with Dispatchers.IO, then update _downloads and _isLoading on the
main thread after the fetch completes. Also, ensure you import
androidx.lifecycle.viewModelScope and have coroutine dependencies configured.
| assetId = "BEArYFdaFbt", | ||
| accessToken = "e6a1b485-daad-42eb-8cf2-6b6e51631092", |
There was a problem hiding this comment.
Avoid committing credentials / tokens to VCS
assetId and especially accessToken are hard-coded. Access tokens are usually short-lived secrets and should reside in secure storage (remote config, keystore, or injected via CI build configs).
At minimum, move them to BuildConfig fields that are excluded from version control.
-assetId = "BEArYFdaFbt",
-accessToken = "e6a1b485-daad-42eb-8cf2-6b6e51631092",
+assetId = BuildConfig.TP_ASSET_ID,
+accessToken = BuildConfig.TP_ACCESS_TOKEN,Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt around lines 15
to 16, the assetId and accessToken are hard-coded, exposing sensitive
credentials in version control. To fix this, remove these hard-coded values and
instead retrieve them from BuildConfig fields or a secure storage mechanism.
Configure these fields in your build system or CI pipeline so that the secrets
are injected at build time and not stored in the source code repository.
| adapter.registerAdapterDataObserver(object : androidx.recyclerview.widget.RecyclerView.AdapterDataObserver() { | ||
| override fun onChanged() { | ||
| checkEmptyState() | ||
| } | ||
| }) | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Unregister the AdapterDataObserver to avoid leaking the Activity
RecyclerView.AdapterDataObserver holds a strong reference to the outer Activity via the anonymous class.
Add onDestroy { adapter.unregisterAdapterDataObserver(observer) } (where observer is stored in a field) or drop the observer since checkEmptyState() is already invoked from the downloads LiveData observer.
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadsActivity.kt around lines 38
to 43, the AdapterDataObserver is registered anonymously without being
unregistered, causing a potential memory leak by holding a strong reference to
the Activity. To fix this, store the observer instance in a field, then override
onDestroy() to unregister this observer from the adapter using
adapter.unregisterAdapterDataObserver(observer). Alternatively, if
checkEmptyState() is already called from the downloads LiveData observer, you
can remove the AdapterDataObserver registration entirely.
| private fun showPopupMenu(view: View, downloadItem: DownloadItem) { | ||
| val popupMenu = PopupMenu(this, view) | ||
|
|
||
| when (downloadItem.state) { | ||
| Download.STATE_COMPLETED -> { | ||
| popupMenu.menu.add(getString(R.string.delete_download_title)) | ||
| } | ||
| Download.STATE_DOWNLOADING -> { | ||
| popupMenu.menu.add(getString(R.string.pause_download)) | ||
| popupMenu.menu.add(getString(R.string.cancel_download)) | ||
| } | ||
| Download.STATE_STOPPED -> { | ||
| popupMenu.menu.add(getString(R.string.resume_download)) | ||
| popupMenu.menu.add(getString(R.string.cancel_download)) | ||
| } | ||
| else -> { | ||
| popupMenu.menu.add(getString(R.string.cancel_download)) | ||
| } | ||
| } | ||
|
|
||
| popupMenu.setOnMenuItemClickListener { menuItem -> | ||
| when (menuItem.title) { | ||
| getString(R.string.delete_download_title), getString(R.string.cancel_download) -> { | ||
| viewModel.removeDownload(downloadItem.assetId) | ||
| } | ||
| getString(R.string.pause_download) -> { | ||
| viewModel.pauseDownload(downloadItem.assetId) | ||
| } | ||
| getString(R.string.resume_download) -> { | ||
| viewModel.resumeDownload(downloadItem.assetId) | ||
| } | ||
| } | ||
| true | ||
| } | ||
|
|
||
| popupMenu.show() | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Use stable menu item IDs instead of comparing titles
Comparing menuItem.title against translated strings can break in localized builds. Create items with fixed IDs and switch on item.itemId:
- when (downloadItem.state) {
+ when (downloadItem.state) {
Download.STATE_COMPLETED -> {
- popupMenu.menu.add(getString(R.string.delete_download_title))
+ popupMenu.menu.add(0, R.id.menu_delete, 0, R.string.delete_download_title)
}
...
}
- popupMenu.setOnMenuItemClickListener { menuItem ->
- when (menuItem.title) {
- getString(R.string.delete_download_title), getString(R.string.cancel_download) -> {
+ popupMenu.setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ R.id.menu_delete, R.id.menu_cancel -> {
viewModel.removeDownload(downloadItem.assetId)
}
- getString(R.string.pause_download) -> {
+ R.id.menu_pause -> {
viewModel.pauseDownload(downloadItem.assetId)
}
- getString(R.string.resume_download) -> {
+ R.id.menu_resume -> {
viewModel.resumeDownload(downloadItem.assetId)
}
}
true
}Remember to declare these IDs in res/values/ids.xml or use existing ones.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private fun showPopupMenu(view: View, downloadItem: DownloadItem) { | |
| val popupMenu = PopupMenu(this, view) | |
| when (downloadItem.state) { | |
| Download.STATE_COMPLETED -> { | |
| popupMenu.menu.add(getString(R.string.delete_download_title)) | |
| } | |
| Download.STATE_DOWNLOADING -> { | |
| popupMenu.menu.add(getString(R.string.pause_download)) | |
| popupMenu.menu.add(getString(R.string.cancel_download)) | |
| } | |
| Download.STATE_STOPPED -> { | |
| popupMenu.menu.add(getString(R.string.resume_download)) | |
| popupMenu.menu.add(getString(R.string.cancel_download)) | |
| } | |
| else -> { | |
| popupMenu.menu.add(getString(R.string.cancel_download)) | |
| } | |
| } | |
| popupMenu.setOnMenuItemClickListener { menuItem -> | |
| when (menuItem.title) { | |
| getString(R.string.delete_download_title), getString(R.string.cancel_download) -> { | |
| viewModel.removeDownload(downloadItem.assetId) | |
| } | |
| getString(R.string.pause_download) -> { | |
| viewModel.pauseDownload(downloadItem.assetId) | |
| } | |
| getString(R.string.resume_download) -> { | |
| viewModel.resumeDownload(downloadItem.assetId) | |
| } | |
| } | |
| true | |
| } | |
| popupMenu.show() | |
| } | |
| private fun showPopupMenu(view: View, downloadItem: DownloadItem) { | |
| val popupMenu = PopupMenu(this, view) | |
| when (downloadItem.state) { | |
| Download.STATE_COMPLETED -> { | |
| popupMenu.menu.add(0, R.id.menu_delete, 0, R.string.delete_download_title) | |
| } | |
| Download.STATE_DOWNLOADING -> { | |
| popupMenu.menu.add(0, R.id.menu_pause, 0, R.string.pause_download) | |
| popupMenu.menu.add(0, R.id.menu_cancel, 0, R.string.cancel_download) | |
| } | |
| Download.STATE_STOPPED -> { | |
| popupMenu.menu.add(0, R.id.menu_resume, 0, R.string.resume_download) | |
| popupMenu.menu.add(0, R.id.menu_cancel, 0, R.string.cancel_download) | |
| } | |
| else -> { | |
| popupMenu.menu.add(0, R.id.menu_cancel, 0, R.string.cancel_download) | |
| } | |
| } | |
| popupMenu.setOnMenuItemClickListener { menuItem -> | |
| when (menuItem.itemId) { | |
| R.id.menu_delete, R.id.menu_cancel -> { | |
| viewModel.removeDownload(downloadItem.assetId) | |
| } | |
| R.id.menu_pause -> { | |
| viewModel.pauseDownload(downloadItem.assetId) | |
| } | |
| R.id.menu_resume -> { | |
| viewModel.resumeDownload(downloadItem.assetId) | |
| } | |
| } | |
| true | |
| } | |
| popupMenu.show() | |
| } |
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadsActivity.kt between lines 75
and 111, the popup menu item click handling compares menuItem.title against
localized strings, which can break in localized builds. To fix this, assign
fixed, stable IDs to each menu item when adding them to the popup menu, then in
the setOnMenuItemClickListener switch on menuItem.itemId instead of title.
Define these IDs in res/values/ids.xml or use existing resource IDs to ensure
consistency across locales.
| class DownloadViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { | ||
| private val titleText: TextView = itemView.findViewById(R.id.title_text) | ||
| private val assetIdText: TextView = itemView.findViewById(R.id.asset_id_text) | ||
| private val progressBar: ProgressBar = itemView.findViewById(R.id.download_progress) | ||
| private val progressText: TextView = itemView.findViewById(R.id.progress_text) | ||
| private val stateText: TextView = itemView.findViewById(R.id.state_text) | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Replace brittle findViewById with View Binding
Manual view look-ups are verbose, error-prone, and prevent the compiler from catching missing IDs.
Switching to View Binding (or Kotlin synthetic properties if you’re still on KTX) will:
• Remove the repeated casts
• Guarantee non-null references at compile time
• Reduce boilerplate in onCreateViewHolder / ViewHolder
Example refactor (View Binding):
-class DownloadViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
- private val titleText: TextView = itemView.findViewById(R.id.title_text)
- ...
+class DownloadViewHolder(
+ private val binding: ItemDownloadBinding
+) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(downloadItem: DownloadItem) {
+ binding.titleText.text = downloadItem.title
+ ...And in onCreateViewHolder:
- val view = LayoutInflater.from(parent.context).inflate(R.layout.item_download, parent, false)
- return DownloadViewHolder(view)
+ val binding = ItemDownloadBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return DownloadViewHolder(binding)Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadAdapter.kt around lines 25 to
31, replace the manual findViewById calls in DownloadViewHolder with View
Binding to avoid verbose and error-prone code. Enable View Binding in the
module, create a binding property for the item layout, and use it to directly
access views without casting. Update onCreateViewHolder to inflate the binding
instead of the layout and pass the binding.root to the ViewHolder constructor,
then access views through the binding instance inside the ViewHolder.
| val progress = downloadItem.progressPercentage.toInt() | ||
| progressBar.progress = progress | ||
|
|
||
| // Show only percentage | ||
| progressText.text = "$progress%" |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Clamp / guard progress to avoid illegal values
progressPercentage can legally be C.PERCENTAGE_UNSET (‐1) or occasionally exceed 100 on bad manifests.
Directly casting to Int and assigning will:
• Display a “-1%” label
• Throw IllegalArgumentException on some OEM progress bars that validate range
-val progress = downloadItem.progressPercentage.toInt()
-progressBar.progress = progress
-progressText.text = "$progress%"
+val raw = downloadItem.progressPercentage
+val clamped = raw.coerceIn(0f, 100f).toInt()
+progressBar.isIndeterminate = raw < 0
+progressBar.progress = clamped
+progressText.text = if (raw < 0) "—" else "$clamped%"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| val progress = downloadItem.progressPercentage.toInt() | |
| progressBar.progress = progress | |
| // Show only percentage | |
| progressText.text = "$progress%" | |
| val raw = downloadItem.progressPercentage | |
| val clamped = raw.coerceIn(0f, 100f).toInt() | |
| progressBar.isIndeterminate = raw < 0 | |
| progressBar.progress = clamped | |
| // Show only percentage | |
| progressText.text = if (raw < 0) "—" else "$clamped%" |
🤖 Prompt for AI Agents
In app/src/main/java/com/tpstreams/player/DownloadAdapter.kt around lines 37 to
41, the progress value derived from downloadItem.progressPercentage can be -1 or
exceed 100, which causes invalid progress bar states and exceptions. To fix
this, clamp the progress value to a valid range between 0 and 100 before
assigning it to progressBar.progress and displaying it in progressText. This
prevents illegal values and ensures safe UI updates.
Summary by CodeRabbit
New Features
User Interface
Localization