summaryrefslogtreecommitdiffstats
path: root/src/com/cyanogenmod/eleven/provider/LocalizedStore.java
blob: 43367e66ffdde5a799140831c2acfabb7be2805a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
/*
 * Copyright (C) 2014 The CyanogenMod Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License
 */
package com.cyanogenmod.eleven.provider;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.SystemClock;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio.AudioColumns;
import android.text.TextUtils;
import android.util.Log;

import com.cyanogenmod.eleven.loaders.SortedCursor;
import com.cyanogenmod.eleven.locale.LocaleSet;
import com.cyanogenmod.eleven.locale.LocaleSetManager;
import com.cyanogenmod.eleven.locale.LocaleUtils;
import com.cyanogenmod.eleven.utils.MusicUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import libcore.icu.ICU;

/**
 * Because sqlite localized collator isn't sufficient, we need to store more specialized logic
 * into our own db similar to contacts db.  This is most noticeable in languages like Chinese,
 * Japanese etc
 */
public class LocalizedStore {
    private static final String TAG = LocalizedStore.class.getSimpleName();
    private static final boolean DEBUG = false;
    private static LocalizedStore sInstance = null;

    private static final int LOCALE_CHANGED = 0;

    private final MusicDB mMusicDatabase;
    private final Context mContext;
    private final ContentValues mContentValues = new ContentValues(10);
    private final LocaleSetManager mLocaleSetManager;

    private final HandlerThread mHandlerThread;
    private final Handler mHandler;

    public enum SortParameter {
        Song,
        Artist,
        Album,
    };

    private static class SortData {
        long[] ids;
        List<String> bucketLabels;
    }

    /**
     * @param context The {@link android.content.Context} to use
     * @return A new instance of this class.
     */
    public static final synchronized LocalizedStore getInstance(final Context context) {
        if (sInstance == null) {
            sInstance = new LocalizedStore(context.getApplicationContext());
        }
        return sInstance;
    }

    private LocalizedStore(final Context context) {
        mMusicDatabase = MusicDB.getInstance(context);
        mContext = context;
        mLocaleSetManager = new LocaleSetManager(mContext);

        mHandlerThread = new HandlerThread("LocalizedStoreWorker",
                android.os.Process.THREAD_PRIORITY_BACKGROUND);
        mHandlerThread.start();
        mHandler = new Handler(mHandlerThread.getLooper()) {
            @Override
            public void handleMessage(Message msg) {
                if (msg.what == LOCALE_CHANGED && mLocaleSetManager.localeSetNeedsUpdate()) {
                    rebuildLocaleData(mLocaleSetManager.getSystemLocaleSet());
                }
            }
        };

        // check to see if locale has changed
        onLocaleChanged();
    }

    public void onCreate(final SQLiteDatabase db) {

        String[] tables = new String[]{
            "CREATE TABLE IF NOT EXISTS " + SongSortColumns.TABLE_NAME + "(" +
                    SongSortColumns.ID + " INTEGER PRIMARY KEY," +
                    SongSortColumns.ARTIST_ID + " INTEGER NOT NULL," +
                    SongSortColumns.ALBUM_ID + " INTEGER NOT NULL," +
                    SongSortColumns.NAME + " TEXT," +
                    SongSortColumns.NAME_LABEL + " TEXT," +
                    SongSortColumns.NAME_BUCKET + " INTEGER);",

            "CREATE TABLE IF NOT EXISTS " + AlbumSortColumns.TABLE_NAME + "(" +
                    AlbumSortColumns.ID + " INTEGER PRIMARY KEY," +
                    AlbumSortColumns.ARTIST_ID + " INTEGER NOT NULL," +
                    AlbumSortColumns.NAME + " TEXT COLLATE LOCALIZED," +
                    AlbumSortColumns.NAME_LABEL + " TEXT," +
                    AlbumSortColumns.NAME_BUCKET + " INTEGER);",

            "CREATE TABLE IF NOT EXISTS " + ArtistSortColumns.TABLE_NAME + "(" +
                    ArtistSortColumns.ID + " INTEGER PRIMARY KEY," +
                    ArtistSortColumns.NAME + " TEXT COLLATE LOCALIZED," +
                    ArtistSortColumns.NAME_LABEL + " TEXT," +
                    ArtistSortColumns.NAME_BUCKET + " INTEGER);",
        };

        for (String table : tables) {
            if (DEBUG) {
                Log.d(TAG, "Creating table: " + table);
            }
            db.execSQL(table);
        }
    }

    public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {
        // this table was created in version 3 so call the onCreate method if we hit that scenario
        if (oldVersion < 3 && newVersion >= 3) {
            onCreate(db);
        }
    }

    public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // If we ever have downgrade, drop the table to be safe
        db.execSQL("DROP TABLE IF EXISTS " + SongSortColumns.TABLE_NAME);
        db.execSQL("DROP TABLE IF EXISTS " + AlbumSortColumns.TABLE_NAME);
        db.execSQL("DROP TABLE IF EXISTS " + ArtistSortColumns.TABLE_NAME);
        onCreate(db);
    }

    public void onLocaleChanged() {
        mHandler.obtainMessage(LOCALE_CHANGED).sendToTarget();
    }

    private void rebuildLocaleData(LocaleSet locales) {
        if (DEBUG) {
            Log.d(TAG, "Locale has changed, rebuilding sorting data");
        }

        final long start = SystemClock.elapsedRealtime();
        final SQLiteDatabase db = mMusicDatabase.getWritableDatabase();
        db.beginTransaction();
        try {
            db.execSQL("DELETE FROM " + SongSortColumns.TABLE_NAME);
            db.execSQL("DELETE FROM " + AlbumSortColumns.TABLE_NAME);
            db.execSQL("DELETE FROM " + ArtistSortColumns.TABLE_NAME);

            // prep the localization classes
            mLocaleSetManager.updateLocaleSet(locales);

            updateLocalizedStore(db, null);

            // Update the ICU version used to generate the locale derived data
            // so we can tell when we need to rebuild with new ICU versions.
            PropertiesStore.getInstance(mContext).storeProperty(
                    PropertiesStore.DbProperties.ICU_VERSION, ICU.getIcuVersion());
            PropertiesStore.getInstance(mContext).storeProperty(PropertiesStore.DbProperties.LOCALE,
                    locales.toString());

            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }

        if (DEBUG) {
            Log.i(TAG, "Locale change completed in " + (SystemClock.elapsedRealtime() - start) + "ms");
        }
    }

    /**
     * This will grab all the songs from the medistore and add the localized data to the db
     * @param selection if we only want to do this for some songs, this selection will filter it out
     */
    private void updateLocalizedStore(final SQLiteDatabase db, final String selection) {
        db.beginTransaction();
        try {
            Cursor cursor = null;

            try {
                final String combinedSelection = MusicUtils.MUSIC_ONLY_SELECTION +
                        (TextUtils.isEmpty(selection) ? "" : " AND " + selection);

                // order by artist/album/id to minimize artist/album re-inserts
                final String orderBy = AudioColumns.ARTIST_ID + "," + AudioColumns.ALBUM + ","
                        + AudioColumns._ID;

                if (DEBUG) {
                    Log.d(TAG, "Running selection query: " + combinedSelection);
                }

                cursor = mContext.getContentResolver().query(
                        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                        new String[]{
                                // 0
                                AudioColumns._ID,
                                // 1
                                AudioColumns.TITLE,
                                // 2
                                AudioColumns.ARTIST_ID,
                                // 3
                                AudioColumns.ARTIST,
                                // 4
                                AudioColumns.ALBUM_ID,
                                // 5
                                AudioColumns.ALBUM,
                        }, combinedSelection, null, orderBy);

                long previousArtistId = -1;
                long previousAlbumId = -1;
                long artistId;
                long albumId;

                if (cursor != null && cursor.moveToFirst()) {
                    do {
                        albumId = cursor.getLong(4);
                        artistId = cursor.getLong(2);

                        if (artistId != previousArtistId) {
                            previousArtistId = artistId;
                            updateArtistData(db, artistId, cursor.getString(3));
                        }

                        if (albumId != previousAlbumId) {
                            previousAlbumId = albumId;

                            updateAlbumData(db, albumId, cursor.getString(5), artistId);
                        }

                        updateSongData(db, cursor.getLong(0), cursor.getString(1), artistId, albumId);
                    } while (cursor.moveToNext());
                }
            } finally {
                if (cursor != null) {
                    cursor.close();
                    cursor = null;
                }
            }

            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void updateArtistData(SQLiteDatabase db, long id, String name) {
        mContentValues.clear();
        name = MusicUtils.getTrimmedName(name);

        final LocaleUtils localeUtils = LocaleUtils.getInstance();
        final int bucketIndex = localeUtils.getBucketIndex(name);

        mContentValues.put(ArtistSortColumns.ID, id);
        mContentValues.put(ArtistSortColumns.NAME, name);
        mContentValues.put(ArtistSortColumns.NAME_BUCKET, bucketIndex);
        mContentValues.put(ArtistSortColumns.NAME_LABEL,
                localeUtils.getBucketLabel(bucketIndex));

        db.insertWithOnConflict(ArtistSortColumns.TABLE_NAME, null, mContentValues,
                SQLiteDatabase.CONFLICT_IGNORE);
    }

    private void updateAlbumData(SQLiteDatabase db, long id, String name, long artistId) {
        mContentValues.clear();
        name = MusicUtils.getTrimmedName(name);

        final LocaleUtils localeUtils = LocaleUtils.getInstance();
        final int bucketIndex = localeUtils.getBucketIndex(name);

        mContentValues.put(AlbumSortColumns.ID, id);
        mContentValues.put(AlbumSortColumns.NAME, name);
        mContentValues.put(AlbumSortColumns.NAME_BUCKET, bucketIndex);
        mContentValues.put(AlbumSortColumns.NAME_LABEL,
                localeUtils.getBucketLabel(bucketIndex));
        mContentValues.put(AlbumSortColumns.ARTIST_ID, artistId);

        db.insertWithOnConflict(AlbumSortColumns.TABLE_NAME, null, mContentValues,
                SQLiteDatabase.CONFLICT_IGNORE);
    }

    private void updateSongData(SQLiteDatabase db, long id, String name, long artistId,
                                long albumId) {
        mContentValues.clear();
        name = MusicUtils.getTrimmedName(name);

        final LocaleUtils localeUtils = LocaleUtils.getInstance();
        final int bucketIndex = localeUtils.getBucketIndex(name);

        mContentValues.put(SongSortColumns.ID, id);
        mContentValues.put(SongSortColumns.NAME, name);
        mContentValues.put(SongSortColumns.NAME_BUCKET, bucketIndex);
        mContentValues.put(SongSortColumns.NAME_LABEL,
                localeUtils.getBucketLabel(bucketIndex));
        mContentValues.put(SongSortColumns.ARTIST_ID, artistId);
        mContentValues.put(SongSortColumns.ALBUM_ID, albumId);

        db.insertWithOnConflict(SongSortColumns.TABLE_NAME, null, mContentValues,
                SQLiteDatabase.CONFLICT_IGNORE);
    }

    /**
     * Gets the list of saved ids and labels for the itemType in localized sorted order
     * @param itemType the type of item we're querying for (artists, albums, songs)
     * @param sortType the type we want to sort by (eg songs sorted by artists,
     *                 albums sorted by artists).  Note some combinations don't make sense and
     *                 will fallback to the basic sort, for example Artists sorted by songs
     *                 doesn't make sense
     * @param descending Whether we want to sort ascending or descending.  This will only apply to
     *                  the basic searches (ie when sortType == itemType),
     *                  otherwise ascending is always assumed
     * @return sorted list of ids and bucket labels for the itemType
     */
    public SortData getSortOrder(SortParameter itemType, SortParameter sortType,
                                boolean descending) {
        SortData sortData = new SortData();
        String tableName = "";
        String joinClause = "";
        String selectParams = "";
        String postfixOrder = "";
        String prefixOrder = "";

        switch (itemType) {
            case Song:
                selectParams = SongSortColumns.CONCRETE_ID + ",";
                postfixOrder = SongSortColumns.getOrderBy(descending);
                tableName = SongSortColumns.TABLE_NAME;

                if (sortType == SortParameter.Artist) {
                    selectParams += ArtistSortColumns.NAME_LABEL;
                    prefixOrder = ArtistSortColumns.getOrderBy(false) + ",";
                    joinClause = createJoin(ArtistSortColumns.TABLE_NAME,
                            SongSortColumns.ARTIST_ID, ArtistSortColumns.CONCRETE_ID);
                } else if (sortType == SortParameter.Album) {
                    selectParams += AlbumSortColumns.NAME_LABEL;
                    prefixOrder = AlbumSortColumns.getOrderBy(false) + ",";
                    joinClause = createJoin(AlbumSortColumns.TABLE_NAME,
                            SongSortColumns.ALBUM_ID, AlbumSortColumns.CONCRETE_ID);
                } else {
                    selectParams += SongSortColumns.NAME_LABEL;
                }
                break;
            case Artist:
                selectParams = ArtistSortColumns.CONCRETE_ID + "," + ArtistSortColumns.NAME_LABEL;
                postfixOrder = ArtistSortColumns.getOrderBy(descending);
                tableName = ArtistSortColumns.TABLE_NAME;
                break;
            case Album:
                selectParams = AlbumSortColumns.CONCRETE_ID + ",";
                postfixOrder = AlbumSortColumns.getOrderBy(descending);
                tableName = AlbumSortColumns.TABLE_NAME;
                if (sortType == SortParameter.Artist) {
                    selectParams += AlbumSortColumns.NAME_LABEL;
                    prefixOrder = ArtistSortColumns.getOrderBy(false) + ",";
                    joinClause = createJoin(ArtistSortColumns.TABLE_NAME,
                            AlbumSortColumns.ARTIST_ID, ArtistSortColumns.CONCRETE_ID);
                } else {
                    selectParams += AlbumSortColumns.NAME_LABEL;
                }
                break;
        }

        final String selection = "SELECT " + selectParams
                + " FROM " + tableName
                + joinClause
                + " ORDER BY " + prefixOrder + postfixOrder;

        if (DEBUG) {
            Log.d(TAG, "Running selection: " + selection);
        }

        Cursor c = null;
        try {
            c = mMusicDatabase.getReadableDatabase().rawQuery(selection, null);

            if (c != null && c.moveToFirst()) {
                sortData.ids = new long[c.getCount()];
                sortData.bucketLabels = new ArrayList<String>(c.getCount());
                do {
                    sortData.ids[c.getPosition()] = c.getLong(0);
                    sortData.bucketLabels.add(c.getString(1));
                } while (c.moveToNext());
            }
        } finally {
            if (c != null) {
                c.close();
            }
        }

        return sortData;
    }

    /**
     * Wraps the cursor with a sorted cursor that sorts it in the proper localized order
     * @param cursor underlying cursor to sort
     * @param columnName the column name of the id
     * @param idType the type of item that the cursor contains
     * @param sortType the type to sort by (for example can be song sorted by albums)
     * @param descending descending?
     * @param update do we want to update any discrepencies we find - only should be true if the
     *               cursor contains all songs/artists/albums and not a subset
     * @return the sorted cursor
     */
    public Cursor getLocalizedSort(Cursor cursor, String columnName, SortParameter idType,
                                   SortParameter sortType, boolean descending, boolean update) {
        if (cursor != null) {
            SortedCursor sortedCursor = null;

            // iterate up to twice if there are discrepancies found
            for (int i = 0; i < 2; i++) {
                // get the sort order for the sort parameter
                SortData sortData = getSortOrder(idType, sortType, descending);

                // get the sorted cursor based on the sort
                sortedCursor = new SortedCursor(cursor, sortData.ids, columnName,
                        sortData.bucketLabels);

                if (!update || !updateDiscrepancies(sortedCursor, idType)) {
                    break;
                }
            }

            return sortedCursor;
        }

        return cursor;
    }

    /**
     * Updates the localized store based on the cursor
     * @param sortedCursor the current sorting cursor based on the LocalizedStore sort
     * @param type the item type in the cursor
     * @return true if there are new ids in the cursor that aren't tracked in the store
     */
    private boolean updateDiscrepancies(SortedCursor sortedCursor, SortParameter type) {
        boolean hasNewIds = false;

        final ArrayList<Long> missingIds = sortedCursor.getMissingIds();
        if (missingIds.size() > 0) {
            removeIds(missingIds, type);
        }

        final Collection<Long> extraIds = sortedCursor.getExtraIds();
        if (extraIds != null && extraIds.size() > 0) {
            addIds(extraIds, type);
            hasNewIds = true;
        }

        return hasNewIds;
    }

    private void removeIds(ArrayList<Long> ids, SortParameter idType) {
        if (ids == null || ids.size() == 0) {
            return;
        }

        final String inParams = "(" + MusicUtils.buildCollectionAsString(ids) + ")";

        if (DEBUG) {
            Log.d(TAG, "Deleting from " + idType + " where id is in " + inParams);
        }

        switch (idType) {
            case Song:
                mMusicDatabase.getWritableDatabase().delete(SongSortColumns.TABLE_NAME,
                        SongSortColumns.ID + " IN " + inParams, null);
                break;
            case Album:
                mMusicDatabase.getWritableDatabase().delete(AlbumSortColumns.TABLE_NAME,
                        AlbumSortColumns.ID + " IN " + inParams, null);
                break;
            case Artist:
                mMusicDatabase.getWritableDatabase().delete(ArtistSortColumns.TABLE_NAME,
                        ArtistSortColumns.ID + " IN " + inParams, null);
                break;
        }
    }

    private void addIds(Collection<Long> ids, SortParameter idType) {
        StringBuilder builder = new StringBuilder();
        switch (idType) {
            case Song:
                builder.append(AudioColumns._ID);
                break;
            case Album:
                builder.append(AudioColumns.ALBUM_ID);
                break;
            case Artist:
                builder.append(AudioColumns.ARTIST_ID);
                break;
        }

        builder.append(" IN (");
        builder.append(MusicUtils.buildCollectionAsString(ids));
        builder.append(")");

        updateLocalizedStore(mMusicDatabase.getWritableDatabase(), builder.toString());
    }

    private static String createJoin(String tableName, String firstParam, String secondParam) {
        return " JOIN " + tableName + " ON (" + firstParam + "=" + secondParam + ")";
    }

    private static String createOrderBy(String first, String second, boolean descending) {
        String desc = descending ? " DESC" : "";
        return first + desc + "," + second + desc;
    }

    private static final class SongSortColumns {
        /* Table name */
        public static final String TABLE_NAME = "song_sort";

        /* Song IDs column */
        public static final String ID = "id";

        /* Artist IDs column */
        public static final String ARTIST_ID = "artist_id";

        /* Album IDs column */
        public static final String ALBUM_ID = "album_id";

        /* The Song name */
        public static final String NAME = "song_name";

        /* The label assigned (categorization buckets - typically the first letter) */
        public static final String NAME_LABEL = "song_name_label";

        /* The numerical index of the bucket */
        public static final String NAME_BUCKET = "song_name_bucket";

        /* Used for joins */
        public static final String CONCRETE_ID = TABLE_NAME + "." + ID;

        public static String getOrderBy(boolean descending) {
            return createOrderBy(NAME_BUCKET, NAME, descending);
        }
    }

    private static final class AlbumSortColumns {

        /* Table name */
        public static final String TABLE_NAME = "album_sort";

        /* Album IDs column */
        public static final String ID = "id";

        /* Artist IDs column */
        public static final String ARTIST_ID = "artist_id";

        /* The Album name */
        public static final String NAME = "album_name";

        /* The label assigned (categorization buckets - typically the first letter) */
        public static final String NAME_LABEL = "album_name_label";

        /* The numerical index of the bucket */
        public static final String NAME_BUCKET = "album_name_bucket";

        /* Used for joins */
        public static final String CONCRETE_ID = TABLE_NAME + "." + ID;

        public static String getOrderBy(boolean descending) {
            return createOrderBy(NAME_BUCKET, NAME, descending);
        }
    }


    private static final class ArtistSortColumns {

        /* Table name */
        public static final String TABLE_NAME = "artist_sort";

        /* Artist IDs column */
        public static final String ID = "id";

        /* The Artist name */
        public static final String NAME = "artist_name";

        /* The label assigned (categorization buckets - typically the first letter) */
        public static final String NAME_LABEL = "artist_name_label";

        /* The numerical index of the bucket */
        public static final String NAME_BUCKET = "artist_name_bucket";

        /* Used for joins */
        public static final String CONCRETE_ID = TABLE_NAME + "." + ID;

        public static String getOrderBy(boolean descending) {
            return createOrderBy(NAME_BUCKET, NAME, descending);
        }
    }

}