1 /* 2 * Copyright 2018 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.pump.provider; 18 19 import android.net.Uri; 20 21 import androidx.annotation.AnyThread; 22 import androidx.annotation.NonNull; 23 import androidx.annotation.Nullable; 24 import androidx.annotation.WorkerThread; 25 26 import com.android.pump.db.Album; 27 import com.android.pump.db.Artist; 28 import com.android.pump.db.DataProvider; 29 import com.android.pump.db.Episode; 30 import com.android.pump.db.Movie; 31 import com.android.pump.db.Series; 32 import com.android.pump.util.Clog; 33 import com.android.pump.util.Http; 34 35 import java.io.IOException; 36 import java.nio.charset.StandardCharsets; 37 38 import org.json.JSONArray; 39 import org.json.JSONException; 40 import org.json.JSONObject; 41 import org.json.JSONTokener; 42 43 @WorkerThread 44 public final class KnowledgeGraph implements DataProvider { 45 private static final String TAG = Clog.tag(KnowledgeGraph.class); 46 47 private static final DataProvider INSTANCE = new KnowledgeGraph(); 48 KnowledgeGraph()49 private KnowledgeGraph() { } 50 51 @AnyThread getInstance()52 public static @NonNull DataProvider getInstance() { 53 return INSTANCE; 54 } 55 56 @Override populateArtist(@onNull Artist artist)57 public boolean populateArtist(@NonNull Artist artist) throws IOException { 58 boolean updated = false; 59 // Artist may be of type "Person" or "MusicGroup" 60 JSONObject result = getResultFromKG(artist.getName(), "Person", "MusicGroup"); 61 62 String imageUrl = getImageUrl(result); 63 if (imageUrl != null) { 64 updated |= artist.setHeadshotUri(Uri.parse(imageUrl)); 65 } 66 String detailedDescription = getDetailedDescription(result); 67 if (detailedDescription != null) { 68 updated |= artist.setDescription(detailedDescription); 69 } 70 return updated; 71 } 72 73 @Override populateAlbum(@onNull Album album)74 public boolean populateAlbum(@NonNull Album album) throws IOException { 75 // Return if album art is already retrieved from the media file 76 if (album.getAlbumArtUri() != null) { 77 return false; 78 } 79 80 boolean updated = false; 81 JSONObject result = getResultFromKG(album.getTitle(), "MusicAlbum"); 82 83 // TODO: (b/128383917) Investigate how to filter search results 84 String imageUrl = getImageUrl(result); 85 if (imageUrl != null) { 86 updated |= album.setAlbumArtUri(Uri.parse(imageUrl)); 87 } 88 String detailedDescription = getDetailedDescription(result); 89 if (detailedDescription != null) { 90 updated |= album.setDescription(detailedDescription); 91 } 92 return updated; 93 } 94 95 @Override populateMovie(@onNull Movie movie)96 public boolean populateMovie(@NonNull Movie movie) throws IOException { 97 boolean updated = false; 98 JSONObject result = getResultFromKG(movie.getTitle(), "Movie"); 99 100 String imageUrl = getImageUrl(result); 101 if (imageUrl != null) { 102 updated |= movie.setPosterUri(Uri.parse(imageUrl)); 103 } 104 String detailedDescription = getDetailedDescription(result); 105 if (detailedDescription != null) { 106 updated |= movie.setDescription(detailedDescription); 107 } 108 return updated; 109 } 110 111 @Override populateSeries(@onNull Series series)112 public boolean populateSeries(@NonNull Series series) throws IOException { 113 boolean updated = false; 114 JSONObject result = getResultFromKG(series.getTitle(), "TVSeries"); 115 116 String imageUrl = getImageUrl(result); 117 if (imageUrl != null) { 118 updated |= series.setPosterUri(Uri.parse(imageUrl)); 119 } 120 String detailedDescription = getDetailedDescription(result); 121 if (detailedDescription != null) { 122 updated |= series.setDescription(detailedDescription); 123 } 124 return updated; 125 } 126 127 @Override populateEpisode(@onNull Episode episode)128 public boolean populateEpisode(@NonNull Episode episode) throws IOException { 129 boolean updated = false; 130 JSONObject result = getResultFromKG(episode.getSeries().getTitle(), "TVEpisode"); 131 132 String imageUrl = getImageUrl(result); 133 if (imageUrl != null) { 134 updated |= episode.setPosterUri(Uri.parse(imageUrl)); 135 } 136 String detailedDescription = getDetailedDescription(result); 137 if (detailedDescription != null) { 138 updated |= episode.setDescription(detailedDescription); 139 } 140 return updated; 141 } 142 getResultFromKG(String title, String... types)143 private @NonNull JSONObject getResultFromKG(String title, String... types) throws IOException { 144 try { 145 JSONObject root = (JSONObject) getContent(getContentUri(title, types)); 146 JSONArray items = root.getJSONArray("itemListElement"); 147 JSONObject item = (JSONObject) items.get(0); 148 JSONObject result = item.getJSONObject("result"); 149 if (!title.equals(result.getString("name"))) { 150 throw new IOException("Failed to find result for " + title); 151 } 152 return result; 153 } catch (JSONException e) { 154 throw new IOException("Failed to find result for " + title); 155 } 156 } 157 getImageUrl(@onNull JSONObject result)158 private @Nullable String getImageUrl(@NonNull JSONObject result) { 159 String imageUrl = null; 160 try { 161 JSONObject imageObj = result.optJSONObject("image"); 162 if (imageObj != null) { 163 String url = imageObj.getString("contentUrl"); 164 if (url != null) { 165 // TODO (b/125143807): Remove once HTTPS scheme urls are retrieved. 166 imageUrl = url.replaceFirst("^http://", "https://"); 167 } 168 } 169 } catch (JSONException e) { 170 Clog.w(TAG, "Failed to parse image url", e); 171 } 172 return imageUrl; 173 } 174 getDescription(@onNull JSONObject result)175 private @Nullable String getDescription(@NonNull JSONObject result) { 176 String description = null; 177 try { 178 description = result.getString("description"); 179 } catch (JSONException e) { 180 Clog.w(TAG, "Failed to parse description", e); 181 } 182 return description; 183 } 184 getDetailedDescription(@onNull JSONObject result)185 private @Nullable String getDetailedDescription(@NonNull JSONObject result) { 186 String detailedDescription = null; 187 try { 188 JSONObject descriptionObj = result.optJSONObject("detailedDescription"); 189 if (descriptionObj != null) { 190 detailedDescription = descriptionObj.getString("articleBody"); 191 } 192 } catch (JSONException e) { 193 Clog.w(TAG, "Failed to parse detailed description", e); 194 } 195 return detailedDescription; 196 } 197 getContentUri(@onNull String title, @NonNull String... types)198 private static @NonNull Uri getContentUri(@NonNull String title, @NonNull String... types) { 199 Uri.Builder ub = new Uri.Builder(); 200 ub.scheme("https"); 201 ub.authority("kgsearch.googleapis.com"); 202 ub.appendPath("v1"); 203 ub.appendEncodedPath("entities:search"); 204 ub.appendQueryParameter("key", ApiKeys.KG_API); 205 ub.appendQueryParameter("limit", "1"); 206 ub.appendQueryParameter("query", title); 207 for (String type : types) { 208 ub.appendQueryParameter("types", type); 209 } 210 return ub.build(); 211 } 212 getContent(@onNull Uri uri)213 private static @NonNull Object getContent(@NonNull Uri uri) throws IOException, JSONException { 214 return new JSONTokener(new String(Http.get(uri.toString()), StandardCharsets.UTF_8)) 215 .nextValue(); 216 } 217 } 218