1 /* 2 * Copyright (C) 2023 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.healthconnect.phr; 18 19 import static android.health.connect.Constants.DEFAULT_LONG; 20 import static android.health.connect.datatypes.MedicalDataSource.validateMedicalDataSourceIds; 21 import static android.health.connect.datatypes.MedicalResource.validateMedicalResourceType; 22 23 import static java.util.Objects.hash; 24 import static java.util.stream.Collectors.toSet; 25 26 import android.annotation.Nullable; 27 import android.health.connect.ReadMedicalResourcesInitialRequest; 28 import android.health.connect.aidl.ReadMedicalResourcesRequestParcel; 29 30 import com.android.internal.annotations.VisibleForTesting; 31 32 import java.util.Arrays; 33 import java.util.Base64; 34 import java.util.Objects; 35 import java.util.Set; 36 37 /** 38 * Wrapper class for generating a PHR pageToken. 39 * 40 * @hide 41 */ 42 public class PhrPageTokenWrapper { 43 public static final PhrPageTokenWrapper EMPTY_PAGE_TOKEN = new PhrPageTokenWrapper(); 44 private static final String DELIMITER = ","; 45 private static final String INNER_DELIMITER = ";"; 46 // This is used for when we just encode the mLastRowId without using filters. 47 private static final int NUM_OF_ENCODED_FIELDS_WITHOUT_REQUEST_FILTERS = 1; 48 // We currently encode mLastRowId, mRequest.getMedicalResourceType(), and 49 // mRequest.getDataSourceIds(). As we add more filters and need to update the encoding logic, we 50 // need to update this as well. 51 private static final int NUM_OF_ENCODED_FIELDS_WITH_REQUEST_FILTERS = 3; 52 // These are the indices at which we store and retrieve each field used for creating the 53 // pageToken string. 54 private static final int LAST_ROW_ID_INDEX = 0; 55 private static final int MEDICAL_RESOURCE_TYPE_INDEX = 1; 56 private static final int MEDICAL_DATA_SOURCE_IDS_INDEX = 2; 57 58 @Nullable private final ReadMedicalResourcesInitialRequest mRequest; 59 private long mLastRowId = DEFAULT_LONG; 60 61 /** 62 * Creates a {@link PhrPageTokenWrapper} from the given {@code lastRowId}. 63 * 64 * <p>This is currently only used in D2D merge logic where we want to read all data out instead 65 * of using filters. So using this, we can build a {@link PhrPageTokenWrapper} from only {@code 66 * lastRowId} specified. 67 */ from(long lastRowId)68 public static PhrPageTokenWrapper from(long lastRowId) { 69 return new PhrPageTokenWrapper(lastRowId); 70 } 71 72 /** 73 * Creates a {@link PhrPageTokenWrapper} from the given {@link 74 * ReadMedicalResourcesRequestParcel}. 75 * 76 * <p>If {@code pageToken} in the request is {@code null}, the default {@code mLastRowId} is 77 * {@link DEFAULT_LONG}, meaning it's the initial request. 78 * 79 * @throws IllegalArgumentException if {@code pageToken} is empty, or not in valid Base64 80 * scheme; or if the decoded {@code lastRowId} is negative; or if the decoded {@code 81 * medicalResourceType} is not supported; or there are invalid IDs in the decoded {@code 82 * dataSourceId}s. 83 * @throws NumberFormatException if the decoded {@code pageToken} does not contain a parsable 84 * integer. 85 */ from(ReadMedicalResourcesRequestParcel request)86 public static PhrPageTokenWrapper from(ReadMedicalResourcesRequestParcel request) { 87 if (request.getPageToken() == null) { 88 // We create a new request and only populate the read filters. The pageSize will be 89 // unset as we don't use it for encoding/decoding. 90 ReadMedicalResourcesInitialRequest requestWithFiltersOnly = 91 new ReadMedicalResourcesInitialRequest.Builder(request.getMedicalResourceType()) 92 .addDataSourceIds(request.getDataSourceIds()) 93 .build(); 94 return new PhrPageTokenWrapper(requestWithFiltersOnly); 95 } 96 return from(request.getPageToken()); 97 } 98 99 /** 100 * Creates a {@link PhrPageTokenWrapper} from the given {@code pageToken}. Returns {@link 101 * #EMPTY_PAGE_TOKEN} if {@code pageToken} is empty or null. 102 */ fromPageTokenAllowingNull(@ullable String pageToken)103 public static PhrPageTokenWrapper fromPageTokenAllowingNull(@Nullable String pageToken) { 104 if (pageToken == null || pageToken.isEmpty()) { 105 return EMPTY_PAGE_TOKEN; 106 } 107 108 return from(pageToken); 109 } 110 111 /** Creates a {@link PhrPageTokenWrapper} from the given {@code pageToken}. */ 112 @VisibleForTesting from(@ullable String pageToken)113 static PhrPageTokenWrapper from(@Nullable String pageToken) { 114 Base64.Decoder decoder = Base64.getDecoder(); 115 String decodedPageToken = new String(decoder.decode(pageToken)); 116 String[] pageTokenSplit = decodedPageToken.split(DELIMITER, /* limit= */ -1); 117 118 // If the pageToken was built from PhrPageTokenWrapper with request being 119 // null, this will only include the lastRowId. 120 if (pageTokenSplit.length == NUM_OF_ENCODED_FIELDS_WITHOUT_REQUEST_FILTERS) { 121 long lastRowId = Long.parseLong(decodedPageToken); 122 if (lastRowId < 0) { 123 throw new IllegalArgumentException("Invalid pageToken"); 124 } 125 return new PhrPageTokenWrapper(lastRowId); 126 } 127 128 if (pageTokenSplit.length != NUM_OF_ENCODED_FIELDS_WITH_REQUEST_FILTERS) { 129 throw new IllegalArgumentException("Invalid pageToken"); 130 } 131 132 long lastRowId = Long.parseLong(pageTokenSplit[LAST_ROW_ID_INDEX]); 133 if (lastRowId < 0) { 134 throw new IllegalArgumentException("Invalid pageToken"); 135 } 136 137 int medicalResourceType = Integer.parseInt(pageTokenSplit[MEDICAL_RESOURCE_TYPE_INDEX]); 138 try { 139 validateMedicalResourceType(medicalResourceType); 140 } catch (IllegalArgumentException e) { 141 throw new IllegalArgumentException("Invalid pageToken"); 142 } 143 144 // We create a new request and only populate the read filters. The pageSize will be unset as 145 // we don't use it for encoding/decoding. 146 ReadMedicalResourcesInitialRequest.Builder requestWithFiltersOnly = 147 new ReadMedicalResourcesInitialRequest.Builder(medicalResourceType); 148 149 String medicalDataSourceIdsString = pageTokenSplit[MEDICAL_DATA_SOURCE_IDS_INDEX]; 150 if (medicalDataSourceIdsString.isEmpty()) { 151 return new PhrPageTokenWrapper(requestWithFiltersOnly.build(), lastRowId); 152 } 153 154 Set<String> medicalDataSourceIds = 155 Arrays.stream(medicalDataSourceIdsString.split(INNER_DELIMITER)).collect(toSet()); 156 try { 157 validateMedicalDataSourceIds(medicalDataSourceIds); 158 } catch (IllegalArgumentException e) { 159 throw new IllegalArgumentException("Invalid pageToken"); 160 } 161 162 return new PhrPageTokenWrapper( 163 requestWithFiltersOnly.addDataSourceIds(medicalDataSourceIds).build(), lastRowId); 164 } 165 166 /** 167 * Returns a pageToken string encoded from this {@link PhrPageTokenWrapper}. 168 * 169 * @throws IllegalStateException if {@code mLastRowId} is negative. 170 */ encode()171 public String encode() { 172 if (mLastRowId < 0) { 173 throw new IllegalStateException("cannot encode when mLastRowId is negative"); 174 } 175 Base64.Encoder encoder = Base64.getEncoder(); 176 return encoder.encodeToString(toReadableTokenString().getBytes()); 177 } 178 179 /** 180 * Converts this token to a readable string which will be used in {@link 181 * PhrPageTokenWrapper#encode()}. 182 */ toReadableTokenString()183 private String toReadableTokenString() { 184 String lastRowId = String.valueOf(mLastRowId); 185 if (mRequest == null) { 186 return lastRowId; 187 } 188 return String.join( 189 DELIMITER, 190 lastRowId, 191 String.valueOf(mRequest.getMedicalResourceType()), 192 String.join(INNER_DELIMITER, mRequest.getDataSourceIds())); 193 } 194 195 /** Creates a String representation of this {@link PhrPageTokenWrapper}. */ toString()196 public String toString() { 197 return toReadableTokenString(); 198 } 199 200 /** 201 * Returns the last read row_id for the current {@code mRequest}. Default is {@link 202 * DEFAULT_LONG} for the initial request. 203 */ getLastRowId()204 public long getLastRowId() { 205 return mLastRowId; 206 } 207 208 /** 209 * Sets the last row id. 210 * 211 * @throws IllegalStateException if {@code lastRowId} is negative. 212 */ cloneWithNewLastRowId(long lastRowId)213 public PhrPageTokenWrapper cloneWithNewLastRowId(long lastRowId) { 214 if (lastRowId < 0) { 215 throw new IllegalStateException("cannot set mLastRowId to negative"); 216 } 217 return new PhrPageTokenWrapper(mRequest, lastRowId); 218 } 219 220 /** Returns the initial request from which the {@link PhrPageTokenWrapper} is created from. */ 221 @Nullable getRequest()222 public ReadMedicalResourcesInitialRequest getRequest() { 223 return mRequest; 224 } 225 226 /** Indicates whether some other object is "equal to" this one. */ 227 @Override equals(Object o)228 public boolean equals(Object o) { 229 if (this == o) return true; 230 if (!(o instanceof PhrPageTokenWrapper that)) return false; 231 return mLastRowId == that.mLastRowId && Objects.equals(mRequest, that.mRequest); 232 } 233 234 /** Returns a hash code value for the object. */ 235 @Override hashCode()236 public int hashCode() { 237 return hash(getLastRowId(), getRequest()); 238 } 239 PhrPageTokenWrapper()240 private PhrPageTokenWrapper() { 241 this.mRequest = null; 242 } 243 PhrPageTokenWrapper(long lastRowId)244 private PhrPageTokenWrapper(long lastRowId) { 245 this.mRequest = null; 246 this.mLastRowId = lastRowId; 247 } 248 PhrPageTokenWrapper(@ullable ReadMedicalResourcesInitialRequest request)249 private PhrPageTokenWrapper(@Nullable ReadMedicalResourcesInitialRequest request) { 250 this.mRequest = request; 251 } 252 PhrPageTokenWrapper( @ullable ReadMedicalResourcesInitialRequest request, long lastRowId)253 private PhrPageTokenWrapper( 254 @Nullable ReadMedicalResourcesInitialRequest request, long lastRowId) { 255 this.mRequest = request; 256 this.mLastRowId = lastRowId; 257 } 258 } 259