Audio file titles are now localizable

Audio files are now able to have localizable titles. Localizable titles are
identified through the following URI pattern:
Scheme: ContentResolver.SCHEME_ANDROID_RESOURCE
Authority: Package Name of ringtone title provider
First Path Segment: Type of resource (must be "string")
Second Path Segment: Resource ID of title
If an audio file has a title which conforms to this pattern, then the title
will be automatically translated to the correct language by retrieving
the provided string resource from the provided package.

Bug: 30483714
Test: cts-tradefed run cts -m CtsMediaTestCases -t \
android.media.cts.MediaScannerTest

Change-Id: I3a7d54090c4362542d1749841e2d1b351bf779a8
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index b4c9e64..a7f9200 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -3,7 +3,7 @@
         package="com.android.providers.media"
         android:sharedUserId="android.media"
         android:sharedUserLabel="@string/uid_label"
-        android:versionCode="800">
+        android:versionCode="900">
 
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
     <uses-permission android:name="android.permission.WRITE_SETTINGS" />
@@ -52,6 +52,7 @@
         <receiver android:name="MediaScannerReceiver">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED" />
+                <action android:name="android.intent.action.LOCALE_CHANGED" />
             </intent-filter>
             <intent-filter>
                 <action android:name="android.intent.action.MEDIA_MOUNTED" />
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index ec70608..39cd552 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -91,10 +91,6 @@
 import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.util.Log;
-
-import libcore.io.IoUtils;
-import libcore.util.EmptyArray;
-
 import java.io.File;
 import java.io.FileDescriptor;
 import java.io.FileInputStream;
@@ -111,6 +107,8 @@
 import java.util.Locale;
 import java.util.PriorityQueue;
 import java.util.Stack;
+import libcore.io.IoUtils;
+import libcore.util.EmptyArray;
 
 /**
  * Media content provider. See {@link android.provider.MediaStore} for details.
@@ -151,6 +149,7 @@
 
     private StorageManager mStorageManager;
     private AppOpsManager mAppOpsManager;
+    private PackageManager mPackageManager;
 
     // In memory cache of path<->id mappings, to speed up inserts during media scan
     HashMap<String, Long> mDirectoryCache = new HashMap<String, Long>();
@@ -604,6 +603,7 @@
 
         mStorageManager = context.getSystemService(StorageManager.class);
         mAppOpsManager = context.getSystemService(AppOpsManager.class);
+        mPackageManager = context.getPackageManager();
 
         sArtistAlbumsMap.put(MediaStore.Audio.Albums._ID, "audio.album_id AS " +
                 MediaStore.Audio.Albums._ID);
@@ -823,7 +823,7 @@
                 + "duration INTEGER,bookmark INTEGER,artist TEXT,album TEXT,resolution TEXT,"
                 + "tags TEXT,category TEXT,language TEXT,mini_thumb_data TEXT,name TEXT,"
                 + "media_type INTEGER,old_id INTEGER,storage_id INTEGER,is_drm INTEGER,"
-                + "width INTEGER, height INTEGER)");
+                + "width INTEGER, height INTEGER, title_resource_uri TEXT)");
         db.execSQL("CREATE TABLE log (time DATETIME, message TEXT)");
         if (!internal) {
             db.execSQL("CREATE TABLE audio_genres (_id INTEGER PRIMARY KEY,name TEXT NOT NULL)");
@@ -925,6 +925,7 @@
         // collation keys
         db.execSQL("DELETE from albums");
         db.execSQL("DELETE from artists");
+        db.execSQL("ALTER TABLE files ADD COLUMN title_resource_uri TEXT DEFAULT NULL");
         db.execSQL("UPDATE files SET date_modified=0;");
     }
 
@@ -954,7 +955,7 @@
         if (fromVersion < 700) {
             // Anything older than KK is recreated from scratch
             createLatestSchema(db, internal);
-        } else if (fromVersion < 800) {
+        } else if (fromVersion < 900) {
             updateFromKKSchema(db, internal, fromVersion);
         }
 
@@ -2037,6 +2038,77 @@
         }
     }
 
+    /**
+     * Localizable titles conform to this URI pattern:
+     *   Scheme: {@link ContentResolver.SCHEME_ANDROID_RESOURCE}
+     *   Authority: Package Name of ringtone title provider
+     *   First Path Segment: Type of resource (must be "string")
+     *   Second Path Segment: Resource ID of title
+     *
+     * @param title The title to localize
+     * @return The localized title, or {@code null} if unlocalizable
+     * @throws Exception Thrown if the title appears to be localizable, but the localization failed
+     * for any reason. For example, the application from which the localized title is fetched is not
+     * installed, or it does not have the resource which needs to be localized.
+     */
+    private String getLocalizedTitle(String title) throws Exception {
+        try {
+            if (TextUtils.isEmpty(title)) {
+                return null;
+            }
+            final Uri titleUri = Uri.parse(title);
+            final String scheme = titleUri.getScheme();
+            if (!ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)) {
+                return null;
+            }
+            final List<String> pathSegments = titleUri.getPathSegments();
+            if (pathSegments.size() != 2) {
+                Log.e(TAG, "Error getting localized title for " + title + ", must have 2 path "
+                    + "segments");
+                return null;
+            }
+            final String type = pathSegments.get(0);
+            if (!"string".equals(type)) {
+                Log.e(TAG, "Error getting localized title for " + title + ", first path segment "
+                    + "must be \"string\"");
+                return null;
+            }
+            final String packageName = titleUri.getAuthority();
+            final Resources resources = mPackageManager.getResourcesForApplication(packageName);
+            final String resourceIdentifier = pathSegments.get(1);
+            final int id = resources.getIdentifier(resourceIdentifier, type, packageName);
+            return resources.getString(id);
+        } catch (Exception e) {
+            Log.e(TAG, "Error getting localized title for " + title, e);
+            throw e;
+        }
+    }
+
+    private void localizeTitles() {
+        for (DatabaseHelper helper : mDatabases.values()) {
+            final SQLiteDatabase db = helper.getWritableDatabase();
+            try (Cursor c = db.query("files", new String[]{"_id", "title_resource_uri"},
+                "title_resource_uri IS NOT NULL", null, null, null, null)) {
+                while (c.moveToNext()) {
+                    final String id = c.getString(0);
+                    final String titleResourceUri = c.getString(1);
+                    final ContentValues values = new ContentValues();
+                    try {
+                        final String localizedTitle = getLocalizedTitle(titleResourceUri);
+                        values.put("title_key", MediaStore.Audio.keyFor(localizedTitle));
+                        // do a final trim of the title, in case it started with the special
+                        // "sort first" character (ascii \001)
+                        values.put("title", localizedTitle.trim());
+                        db.update("files", values, "_id=?", new String[]{id});
+                    } catch (Exception e) {
+                        Log.e(TAG, "Error updating localized title for " + titleResourceUri
+                            + ", keeping old localization");
+                    }
+                }
+            }
+        }
+    }
+
     private long insertFile(DatabaseHelper helper, Uri uri, ContentValues initialValues, int mediaType,
                             boolean notify, ArrayList<Long> notifyRowIds) {
         SQLiteDatabase db = helper.getWritableDatabase();
@@ -2118,10 +2190,21 @@
                 values.put("album_id", Integer.toString((int)albumRowId));
                 so = values.getAsString("title");
                 s = (so == null ? "" : so.toString());
+
+                try {
+                    final String localizedTitle = getLocalizedTitle(s);
+                    if (localizedTitle != null) {
+                        values.put("title_resource_uri", s);
+                        s = localizedTitle;
+                    } else {
+                        values.putNull("title_resource_uri");
+                    }
+                } catch (Exception e) {
+                    values.put("title_resource_uri", s);
+                }
                 values.put("title_key", MediaStore.Audio.keyFor(s));
                 // do a final trim of the title, in case it started with the special
                 // "sort first" character (ascii \001)
-                values.remove("title");
                 values.put("title", s.trim());
 
                 computeDisplayName(values.getAsString(MediaStore.MediaColumns.DATA), values);
@@ -3308,6 +3391,10 @@
             processRemovedNoMediaPath(arg);
             return null;
         }
+        if (MediaStore.RETRANSLATE_CALL.equals(method)) {
+            localizeTitles();
+            return null;
+        }
         throw new UnsupportedOperationException("Unsupported call: " + method);
     }
 
@@ -3487,12 +3574,21 @@
                     // If the title field is modified, update the title_key
                     so = values.getAsString("title");
                     if (so != null) {
-                        String s = so.toString();
-                        values.put("title_key", MediaStore.Audio.keyFor(s));
+                        try {
+                            final String localizedTitle = getLocalizedTitle(so);
+                            if (localizedTitle != null) {
+                                values.put("title_resource_uri", so);
+                                so = localizedTitle;
+                            } else {
+                                values.putNull("title_resource_uri");
+                            }
+                        } catch (Exception e) {
+                            values.put("title_resource_uri", so);
+                        }
+                        values.put("title_key", MediaStore.Audio.keyFor(so));
                         // do a final trim of the title, in case it started with the special
                         // "sort first" character (ascii \001)
-                        values.remove("title");
-                        values.put("title", s.trim());
+                        values.put("title", so.trim());
                     }
 
                     helper.mNumUpdates++;
diff --git a/src/com/android/providers/media/MediaScannerReceiver.java b/src/com/android/providers/media/MediaScannerReceiver.java
index 8a098af..8107dfe 100644
--- a/src/com/android/providers/media/MediaScannerReceiver.java
+++ b/src/com/android/providers/media/MediaScannerReceiver.java
@@ -23,6 +23,7 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Environment;
+import android.provider.MediaStore;
 import android.util.Log;
 
 import java.io.File;
@@ -38,6 +39,8 @@
         if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
             // Scan internal only.
             scan(context, MediaProvider.INTERNAL_VOLUME);
+        } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) {
+            scanTranslatable(context);
         } else {
             if (uri.getScheme().equals("file")) {
                 // handle intents related to external storage
@@ -72,12 +75,18 @@
         args.putString("volume", volume);
         context.startService(
                 new Intent(context, MediaScannerService.class).putExtras(args));
-    }    
+    }
 
     private void scanFile(Context context, String path) {
         Bundle args = new Bundle();
         args.putString("filepath", path);
         context.startService(
                 new Intent(context, MediaScannerService.class).putExtras(args));
-    }    
+    }
+
+    private void scanTranslatable(Context context) {
+        final Bundle args = new Bundle();
+        args.putBoolean(MediaStore.RETRANSLATE_CALL, true);
+        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
+    }
 }
diff --git a/src/com/android/providers/media/MediaScannerService.java b/src/com/android/providers/media/MediaScannerService.java
index 1120676..8d56054 100644
--- a/src/com/android/providers/media/MediaScannerService.java
+++ b/src/com/android/providers/media/MediaScannerService.java
@@ -18,6 +18,7 @@
 package com.android.providers.media;
 
 import android.app.Service;
+import android.content.ContentProviderClient;
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
@@ -224,6 +225,10 @@
                     if (listener != null) {
                         listener.scanCompleted(filePath, uri);
                     }
+                } else if (arguments.getBoolean(MediaStore.RETRANSLATE_CALL)) {
+                    ContentProviderClient mediaProvider = getBaseContext().getContentResolver()
+                        .acquireContentProviderClient(MediaStore.AUTHORITY);
+                    mediaProvider.call(MediaStore.RETRANSLATE_CALL, null, null);
                 } else {
                     String volume = arguments.getString("volume");
                     String[] directories = null;
@@ -259,5 +264,5 @@
 
             stopSelf(msg.arg1);
         }
-    };
+    }
 }