OSDN Git Service

f5bae7c14d97521526c3ef94ae8b305f6d589c8f
[android-x86/frameworks-base.git] / services / core / java / com / android / server / LockSettingsStorage.java
1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16
17 package com.android.server;
18
19 import static android.content.Context.USER_SERVICE;
20
21 import android.content.ContentValues;
22 import android.content.Context;
23 import android.content.pm.UserInfo;
24 import android.database.Cursor;
25 import android.database.sqlite.SQLiteDatabase;
26 import android.database.sqlite.SQLiteOpenHelper;
27 import android.os.Environment;
28 import android.os.UserManager;
29 import android.util.ArrayMap;
30 import android.util.Log;
31 import android.util.Slog;
32
33 import com.android.internal.annotations.VisibleForTesting;
34 import com.android.internal.util.ArrayUtils;
35 import com.android.internal.widget.LockPatternUtils;
36
37 import java.io.File;
38 import java.io.IOException;
39 import java.io.RandomAccessFile;
40
41 /**
42  * Storage for the lock settings service.
43  */
44 class LockSettingsStorage {
45
46     private static final String TAG = "LockSettingsStorage";
47     private static final String TABLE = "locksettings";
48     private static final boolean DEBUG = false;
49
50     private static final String COLUMN_KEY = "name";
51     private static final String COLUMN_USERID = "user";
52     private static final String COLUMN_VALUE = "value";
53
54     private static final String[] COLUMNS_FOR_QUERY = {
55             COLUMN_VALUE
56     };
57     private static final String[] COLUMNS_FOR_PREFETCH = {
58             COLUMN_KEY, COLUMN_VALUE
59     };
60
61     private static final String SYSTEM_DIRECTORY = "/system/";
62     private static final String LOCK_PATTERN_FILE = "gatekeeper.pattern.key";
63     private static final String BASE_ZERO_LOCK_PATTERN_FILE = "gatekeeper.gesture.key";
64     private static final String LEGACY_LOCK_PATTERN_FILE = "gesture.key";
65     private static final String LOCK_PASSWORD_FILE = "gatekeeper.password.key";
66     private static final String LEGACY_LOCK_PASSWORD_FILE = "password.key";
67     private static final String CHILD_PROFILE_LOCK_FILE = "gatekeeper.profile.key";
68
69     private static final String SYNTHETIC_PASSWORD_DIRECTORY = "spblob/";
70
71     private static final Object DEFAULT = new Object();
72
73     private final DatabaseHelper mOpenHelper;
74     private final Context mContext;
75     private final Cache mCache = new Cache();
76     private final Object mFileWriteLock = new Object();
77
78     @VisibleForTesting
79     public static class CredentialHash {
80         static final int VERSION_LEGACY = 0;
81         static final int VERSION_GATEKEEPER = 1;
82
83         private CredentialHash(byte[] hash, int type, int version) {
84             if (type != LockPatternUtils.CREDENTIAL_TYPE_NONE) {
85                 if (hash == null) {
86                     throw new RuntimeException("Empty hash for CredentialHash");
87                 }
88             } else /* type == LockPatternUtils.CREDENTIAL_TYPE_NONE */ {
89                 if (hash != null) {
90                     throw new RuntimeException("None type CredentialHash should not have hash");
91                 }
92             }
93             this.hash = hash;
94             this.type = type;
95             this.version = version;
96             this.isBaseZeroPattern = false;
97         }
98
99         private CredentialHash(byte[] hash, boolean isBaseZeroPattern) {
100             this.hash = hash;
101             this.type = LockPatternUtils.CREDENTIAL_TYPE_PATTERN;
102             this.version = VERSION_GATEKEEPER;
103             this.isBaseZeroPattern = isBaseZeroPattern;
104         }
105
106         static CredentialHash create(byte[] hash, int type) {
107             if (type == LockPatternUtils.CREDENTIAL_TYPE_NONE) {
108                 throw new RuntimeException("Bad type for CredentialHash");
109             }
110             return new CredentialHash(hash, type, VERSION_GATEKEEPER);
111         }
112
113         static CredentialHash createEmptyHash() {
114             return new CredentialHash(null, LockPatternUtils.CREDENTIAL_TYPE_NONE,
115                     VERSION_GATEKEEPER);
116         }
117
118         byte[] hash;
119         int type;
120         int version;
121         boolean isBaseZeroPattern;
122     }
123
124     public LockSettingsStorage(Context context) {
125         mContext = context;
126         mOpenHelper = new DatabaseHelper(context);
127     }
128
129     public void setDatabaseOnCreateCallback(Callback callback) {
130         mOpenHelper.setCallback(callback);
131     }
132
133     public void writeKeyValue(String key, String value, int userId) {
134         writeKeyValue(mOpenHelper.getWritableDatabase(), key, value, userId);
135     }
136
137     public void writeKeyValue(SQLiteDatabase db, String key, String value, int userId) {
138         ContentValues cv = new ContentValues();
139         cv.put(COLUMN_KEY, key);
140         cv.put(COLUMN_USERID, userId);
141         cv.put(COLUMN_VALUE, value);
142
143         db.beginTransaction();
144         try {
145             db.delete(TABLE, COLUMN_KEY + "=? AND " + COLUMN_USERID + "=?",
146                     new String[] {key, Integer.toString(userId)});
147             db.insert(TABLE, null, cv);
148             db.setTransactionSuccessful();
149             mCache.putKeyValue(key, value, userId);
150         } finally {
151             db.endTransaction();
152         }
153
154     }
155
156     public String readKeyValue(String key, String defaultValue, int userId) {
157         int version;
158         synchronized (mCache) {
159             if (mCache.hasKeyValue(key, userId)) {
160                 return mCache.peekKeyValue(key, defaultValue, userId);
161             }
162             version = mCache.getVersion();
163         }
164
165         Cursor cursor;
166         Object result = DEFAULT;
167         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
168         if ((cursor = db.query(TABLE, COLUMNS_FOR_QUERY,
169                 COLUMN_USERID + "=? AND " + COLUMN_KEY + "=?",
170                 new String[] { Integer.toString(userId), key },
171                 null, null, null)) != null) {
172             if (cursor.moveToFirst()) {
173                 result = cursor.getString(0);
174             }
175             cursor.close();
176         }
177         mCache.putKeyValueIfUnchanged(key, result, userId, version);
178         return result == DEFAULT ? defaultValue : (String) result;
179     }
180
181     public void prefetchUser(int userId) {
182         int version;
183         synchronized (mCache) {
184             if (mCache.isFetched(userId)) {
185                 return;
186             }
187             mCache.setFetched(userId);
188             version = mCache.getVersion();
189         }
190
191         Cursor cursor;
192         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
193         if ((cursor = db.query(TABLE, COLUMNS_FOR_PREFETCH,
194                 COLUMN_USERID + "=?",
195                 new String[] { Integer.toString(userId) },
196                 null, null, null)) != null) {
197             while (cursor.moveToNext()) {
198                 String key = cursor.getString(0);
199                 String value = cursor.getString(1);
200                 mCache.putKeyValueIfUnchanged(key, value, userId, version);
201             }
202             cursor.close();
203         }
204
205         // Populate cache by reading the password and pattern files.
206         readCredentialHash(userId);
207     }
208
209     private CredentialHash readPasswordHashIfExists(int userId) {
210         byte[] stored = readFile(getLockPasswordFilename(userId));
211         if (!ArrayUtils.isEmpty(stored)) {
212             return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PASSWORD,
213                     CredentialHash.VERSION_GATEKEEPER);
214         }
215
216         stored = readFile(getLegacyLockPasswordFilename(userId));
217         if (!ArrayUtils.isEmpty(stored)) {
218             return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PASSWORD,
219                     CredentialHash.VERSION_LEGACY);
220         }
221         return null;
222     }
223
224     private CredentialHash readPatternHashIfExists(int userId) {
225         byte[] stored = readFile(getLockPatternFilename(userId));
226         if (!ArrayUtils.isEmpty(stored)) {
227             return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PATTERN,
228                     CredentialHash.VERSION_GATEKEEPER);
229         }
230
231         stored = readFile(getBaseZeroLockPatternFilename(userId));
232         if (!ArrayUtils.isEmpty(stored)) {
233             return new CredentialHash(stored, true);
234         }
235
236         stored = readFile(getLegacyLockPatternFilename(userId));
237         if (!ArrayUtils.isEmpty(stored)) {
238             return new CredentialHash(stored, LockPatternUtils.CREDENTIAL_TYPE_PATTERN,
239                     CredentialHash.VERSION_LEGACY);
240         }
241         return null;
242     }
243
244     public CredentialHash readCredentialHash(int userId) {
245         CredentialHash passwordHash = readPasswordHashIfExists(userId);
246         CredentialHash patternHash = readPatternHashIfExists(userId);
247         if (passwordHash != null && patternHash != null) {
248             if (passwordHash.version == CredentialHash.VERSION_GATEKEEPER) {
249                 return passwordHash;
250             } else {
251                 return patternHash;
252             }
253         } else if (passwordHash != null) {
254             return passwordHash;
255         } else if (patternHash != null) {
256             return patternHash;
257         } else {
258             return CredentialHash.createEmptyHash();
259         }
260     }
261
262     public void removeChildProfileLock(int userId) {
263         if (DEBUG)
264             Slog.e(TAG, "Remove child profile lock for user: " + userId);
265         try {
266             deleteFile(getChildProfileLockFile(userId));
267         } catch (Exception e) {
268             e.printStackTrace();
269         }
270     }
271
272     public void writeChildProfileLock(int userId, byte[] lock) {
273         writeFile(getChildProfileLockFile(userId), lock);
274     }
275
276     public byte[] readChildProfileLock(int userId) {
277         return readFile(getChildProfileLockFile(userId));
278     }
279
280     public boolean hasChildProfileLock(int userId) {
281         return hasFile(getChildProfileLockFile(userId));
282     }
283
284     public boolean hasPassword(int userId) {
285         return hasFile(getLockPasswordFilename(userId)) ||
286             hasFile(getLegacyLockPasswordFilename(userId));
287     }
288
289     public boolean hasPattern(int userId) {
290         return hasFile(getLockPatternFilename(userId)) ||
291             hasFile(getBaseZeroLockPatternFilename(userId)) ||
292             hasFile(getLegacyLockPatternFilename(userId));
293     }
294
295     public boolean hasCredential(int userId) {
296         return hasPassword(userId) || hasPattern(userId);
297     }
298
299     private boolean hasFile(String name) {
300         byte[] contents = readFile(name);
301         return contents != null && contents.length > 0;
302     }
303
304     private byte[] readFile(String name) {
305         int version;
306         synchronized (mCache) {
307             if (mCache.hasFile(name)) {
308                 return mCache.peekFile(name);
309             }
310             version = mCache.getVersion();
311         }
312
313         RandomAccessFile raf = null;
314         byte[] stored = null;
315         try {
316             raf = new RandomAccessFile(name, "r");
317             stored = new byte[(int) raf.length()];
318             raf.readFully(stored, 0, stored.length);
319             raf.close();
320         } catch (IOException e) {
321             Slog.e(TAG, "Cannot read file " + e);
322         } finally {
323             if (raf != null) {
324                 try {
325                     raf.close();
326                 } catch (IOException e) {
327                     Slog.e(TAG, "Error closing file " + e);
328                 }
329             }
330         }
331         mCache.putFileIfUnchanged(name, stored, version);
332         return stored;
333     }
334
335     private void writeFile(String name, byte[] hash) {
336         synchronized (mFileWriteLock) {
337             RandomAccessFile raf = null;
338             try {
339                 // Write the hash to file
340                 raf = new RandomAccessFile(name, "rw");
341                 // Truncate the file if pattern is null, to clear the lock
342                 if (hash == null || hash.length == 0) {
343                     raf.setLength(0);
344                 } else {
345                     raf.write(hash, 0, hash.length);
346                 }
347                 raf.close();
348             } catch (IOException e) {
349                 Slog.e(TAG, "Error writing to file " + e);
350             } finally {
351                 if (raf != null) {
352                     try {
353                         raf.close();
354                     } catch (IOException e) {
355                         Slog.e(TAG, "Error closing file " + e);
356                     }
357                 }
358             }
359             mCache.putFile(name, hash);
360         }
361     }
362
363     private void deleteFile(String name) {
364         if (DEBUG) Slog.e(TAG, "Delete file " + name);
365         synchronized (mFileWriteLock) {
366             File file = new File(name);
367             if (file.exists()) {
368                 file.delete();
369                 mCache.putFile(name, null);
370             }
371         }
372     }
373
374     public void writeCredentialHash(CredentialHash hash, int userId) {
375         byte[] patternHash = null;
376         byte[] passwordHash = null;
377
378         if (hash.type == LockPatternUtils.CREDENTIAL_TYPE_PASSWORD) {
379             passwordHash = hash.hash;
380         } else if (hash.type == LockPatternUtils.CREDENTIAL_TYPE_PATTERN) {
381             patternHash = hash.hash;
382         }
383         writeFile(getLockPasswordFilename(userId), passwordHash);
384         writeFile(getLockPatternFilename(userId), patternHash);
385     }
386
387     @VisibleForTesting
388     String getLockPatternFilename(int userId) {
389         return getLockCredentialFilePathForUser(userId, LOCK_PATTERN_FILE);
390     }
391
392     @VisibleForTesting
393     String getLockPasswordFilename(int userId) {
394         return getLockCredentialFilePathForUser(userId, LOCK_PASSWORD_FILE);
395     }
396
397     @VisibleForTesting
398     String getLegacyLockPatternFilename(int userId) {
399         return getLockCredentialFilePathForUser(userId, LEGACY_LOCK_PATTERN_FILE);
400     }
401
402     @VisibleForTesting
403     String getLegacyLockPasswordFilename(int userId) {
404         return getLockCredentialFilePathForUser(userId, LEGACY_LOCK_PASSWORD_FILE);
405     }
406
407     private String getBaseZeroLockPatternFilename(int userId) {
408         return getLockCredentialFilePathForUser(userId, BASE_ZERO_LOCK_PATTERN_FILE);
409     }
410
411     @VisibleForTesting
412     String getChildProfileLockFile(int userId) {
413         return getLockCredentialFilePathForUser(userId, CHILD_PROFILE_LOCK_FILE);
414     }
415
416     private String getLockCredentialFilePathForUser(int userId, String basename) {
417         String dataSystemDirectory = Environment.getDataDirectory().getAbsolutePath() +
418                         SYSTEM_DIRECTORY;
419         if (userId == 0) {
420             // Leave it in the same place for user 0
421             return dataSystemDirectory + basename;
422         } else {
423             return new File(Environment.getUserSystemDirectory(userId), basename).getAbsolutePath();
424         }
425     }
426
427     public void writeSyntheticPasswordState(int userId, long handle, String name, byte[] data) {
428         writeFile(getSynthenticPasswordStateFilePathForUser(userId, handle, name), data);
429     }
430
431     public byte[] readSyntheticPasswordState(int userId, long handle, String name) {
432         return readFile(getSynthenticPasswordStateFilePathForUser(userId, handle, name));
433     }
434
435     public void deleteSyntheticPasswordState(int userId, long handle, String name, boolean secure) {
436         String path = getSynthenticPasswordStateFilePathForUser(userId, handle, name);
437         File file = new File(path);
438         if (file.exists()) {
439             //TODO: (b/34600579) invoke secdiscardable
440             file.delete();
441             mCache.putFile(path, null);
442         }
443     }
444
445     @VisibleForTesting
446     protected File getSyntheticPasswordDirectoryForUser(int userId) {
447         return new File(Environment.getDataSystemDeDirectory(userId) ,SYNTHETIC_PASSWORD_DIRECTORY);
448     }
449
450     @VisibleForTesting
451     protected String getSynthenticPasswordStateFilePathForUser(int userId, long handle,
452             String name) {
453         File baseDir = getSyntheticPasswordDirectoryForUser(userId);
454         String baseName = String.format("%016x.%s", handle, name);
455         if (!baseDir.exists()) {
456             baseDir.mkdir();
457         }
458         return new File(baseDir, baseName).getAbsolutePath();
459     }
460
461     public void removeUser(int userId) {
462         SQLiteDatabase db = mOpenHelper.getWritableDatabase();
463
464         final UserManager um = (UserManager) mContext.getSystemService(USER_SERVICE);
465         final UserInfo parentInfo = um.getProfileParent(userId);
466
467         if (parentInfo == null) {
468             // This user owns its lock settings files - safe to delete them
469             synchronized (mFileWriteLock) {
470                 String name = getLockPasswordFilename(userId);
471                 File file = new File(name);
472                 if (file.exists()) {
473                     file.delete();
474                     mCache.putFile(name, null);
475                 }
476                 name = getLockPatternFilename(userId);
477                 file = new File(name);
478                 if (file.exists()) {
479                     file.delete();
480                     mCache.putFile(name, null);
481                 }
482             }
483         } else {
484             // Managed profile
485             removeChildProfileLock(userId);
486         }
487
488         File spStateDir = getSyntheticPasswordDirectoryForUser(userId);
489         try {
490             db.beginTransaction();
491             db.delete(TABLE, COLUMN_USERID + "='" + userId + "'", null);
492             db.setTransactionSuccessful();
493             mCache.removeUser(userId);
494             // The directory itself will be deleted as part of user deletion operation by the
495             // framework, so only need to purge cache here.
496             //TODO: (b/34600579) invoke secdiscardable
497             mCache.purgePath(spStateDir.getAbsolutePath());
498         } finally {
499             db.endTransaction();
500         }
501     }
502
503     @VisibleForTesting
504     void closeDatabase() {
505         mOpenHelper.close();
506     }
507
508     @VisibleForTesting
509     void clearCache() {
510         mCache.clear();
511     }
512
513     public interface Callback {
514         void initialize(SQLiteDatabase db);
515     }
516
517     class DatabaseHelper extends SQLiteOpenHelper {
518         private static final String TAG = "LockSettingsDB";
519         private static final String DATABASE_NAME = "locksettings.db";
520
521         private static final int DATABASE_VERSION = 2;
522
523         private Callback mCallback;
524
525         public DatabaseHelper(Context context) {
526             super(context, DATABASE_NAME, null, DATABASE_VERSION);
527             setWriteAheadLoggingEnabled(true);
528         }
529
530         public void setCallback(Callback callback) {
531             mCallback = callback;
532         }
533
534         private void createTable(SQLiteDatabase db) {
535             db.execSQL("CREATE TABLE " + TABLE + " (" +
536                     "_id INTEGER PRIMARY KEY AUTOINCREMENT," +
537                     COLUMN_KEY + " TEXT," +
538                     COLUMN_USERID + " INTEGER," +
539                     COLUMN_VALUE + " TEXT" +
540                     ");");
541         }
542
543         @Override
544         public void onCreate(SQLiteDatabase db) {
545             createTable(db);
546             if (mCallback != null) {
547                 mCallback.initialize(db);
548             }
549         }
550
551         @Override
552         public void onUpgrade(SQLiteDatabase db, int oldVersion, int currentVersion) {
553             int upgradeVersion = oldVersion;
554             if (upgradeVersion == 1) {
555                 // Previously migrated lock screen widget settings. Now defunct.
556                 upgradeVersion = 2;
557             }
558
559             if (upgradeVersion != DATABASE_VERSION) {
560                 Log.w(TAG, "Failed to upgrade database!");
561             }
562         }
563     }
564
565     /**
566      * Cache consistency model:
567      * - Writes to storage write directly to the cache, but this MUST happen within the atomic
568      *   section either provided by the database transaction or mWriteLock, such that writes to the
569      *   cache and writes to the backing storage are guaranteed to occur in the same order
570      *
571      * - Reads can populate the cache, but because they are no strong ordering guarantees with
572      *   respect to writes this precaution is taken:
573      *   - The cache is assigned a version number that increases every time the cache is modified.
574      *     Reads from backing storage can only populate the cache if the backing storage
575      *     has not changed since the load operation has begun.
576      *     This guarantees that no read operation can shadow a write to the cache that happens
577      *     after it had begun.
578      */
579     private static class Cache {
580         private final ArrayMap<CacheKey, Object> mCache = new ArrayMap<>();
581         private final CacheKey mCacheKey = new CacheKey();
582         private int mVersion = 0;
583
584         String peekKeyValue(String key, String defaultValue, int userId) {
585             Object cached = peek(CacheKey.TYPE_KEY_VALUE, key, userId);
586             return cached == DEFAULT ? defaultValue : (String) cached;
587         }
588
589         boolean hasKeyValue(String key, int userId) {
590             return contains(CacheKey.TYPE_KEY_VALUE, key, userId);
591         }
592
593         void putKeyValue(String key, String value, int userId) {
594             put(CacheKey.TYPE_KEY_VALUE, key, value, userId);
595         }
596
597         void putKeyValueIfUnchanged(String key, Object value, int userId, int version) {
598             putIfUnchanged(CacheKey.TYPE_KEY_VALUE, key, value, userId, version);
599         }
600
601         byte[] peekFile(String fileName) {
602             return (byte[]) peek(CacheKey.TYPE_FILE, fileName, -1 /* userId */);
603         }
604
605         boolean hasFile(String fileName) {
606             return contains(CacheKey.TYPE_FILE, fileName, -1 /* userId */);
607         }
608
609         void putFile(String key, byte[] value) {
610             put(CacheKey.TYPE_FILE, key, value, -1 /* userId */);
611         }
612
613         void putFileIfUnchanged(String key, byte[] value, int version) {
614             putIfUnchanged(CacheKey.TYPE_FILE, key, value, -1 /* userId */, version);
615         }
616
617         void setFetched(int userId) {
618             put(CacheKey.TYPE_FETCHED, "isFetched", "true", userId);
619         }
620
621         boolean isFetched(int userId) {
622             return contains(CacheKey.TYPE_FETCHED, "", userId);
623         }
624
625
626         private synchronized void put(int type, String key, Object value, int userId) {
627             // Create a new CachKey here because it may be saved in the map if the key is absent.
628             mCache.put(new CacheKey().set(type, key, userId), value);
629             mVersion++;
630         }
631
632         private synchronized void putIfUnchanged(int type, String key, Object value, int userId,
633                 int version) {
634             if (!contains(type, key, userId) && mVersion == version) {
635                 put(type, key, value, userId);
636             }
637         }
638
639         private synchronized boolean contains(int type, String key, int userId) {
640             return mCache.containsKey(mCacheKey.set(type, key, userId));
641         }
642
643         private synchronized Object peek(int type, String key, int userId) {
644             return mCache.get(mCacheKey.set(type, key, userId));
645         }
646
647         private synchronized int getVersion() {
648             return mVersion;
649         }
650
651         synchronized void removeUser(int userId) {
652             for (int i = mCache.size() - 1; i >= 0; i--) {
653                 if (mCache.keyAt(i).userId == userId) {
654                     mCache.removeAt(i);
655                 }
656             }
657
658             // Make sure in-flight loads can't write to cache.
659             mVersion++;
660         }
661
662         synchronized void purgePath(String path) {
663             for (int i = mCache.size() - 1; i >= 0; i--) {
664                 CacheKey entry = mCache.keyAt(i);
665                 if (entry.type == CacheKey.TYPE_FILE && entry.key.startsWith(path)) {
666                     mCache.removeAt(i);
667                 }
668             }
669             mVersion++;
670         }
671
672         synchronized void clear() {
673             mCache.clear();
674             mVersion++;
675         }
676
677         private static final class CacheKey {
678             static final int TYPE_KEY_VALUE = 0;
679             static final int TYPE_FILE = 1;
680             static final int TYPE_FETCHED = 2;
681
682             String key;
683             int userId;
684             int type;
685
686             public CacheKey set(int type, String key, int userId) {
687                 this.type = type;
688                 this.key = key;
689                 this.userId = userId;
690                 return this;
691             }
692
693             @Override
694             public boolean equals(Object obj) {
695                 if (!(obj instanceof CacheKey))
696                     return false;
697                 CacheKey o = (CacheKey) obj;
698                 return userId == o.userId && type == o.type && key.equals(o.key);
699             }
700
701             @Override
702             public int hashCode() {
703                 return key.hashCode() ^ userId ^ type;
704             }
705         }
706     }
707
708 }